├── .coveragerc ├── .dockerignore ├── .github └── workflows │ ├── coverage.yml │ └── release-please.yml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── Dockerfile.demo ├── Dockerfile.dev ├── Dockerfile.test ├── LICENSE ├── README.md ├── docker-compose.dev.yml ├── docker-compose.yml ├── docs ├── README.md ├── architecture.md ├── features │ ├── ai.md │ ├── correspondence.md │ ├── dashboard.md │ ├── task_manager.md │ ├── templates.md │ └── transcription.md ├── images │ ├── architecture.png │ ├── chat.png │ ├── correspondence.png │ ├── dashboard.png │ ├── documents.png │ ├── jobs.png │ ├── readme_logo.png │ ├── readme_logo.webp │ ├── reasoning.png │ └── transcription.png ├── overview.md ├── setup.md └── warnings.md ├── package-lock.json ├── package.json ├── public ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── index.html ├── logo.webp ├── robots.txt └── site.webmanifest ├── server ├── __init__.py ├── api │ ├── __init__.py │ ├── chat.py │ ├── config.py │ ├── dashboard.py │ ├── letter.py │ ├── patient.py │ ├── rag.py │ ├── templates.py │ └── transcribe.py ├── database │ ├── __init__.py │ ├── analysis.py │ ├── config.py │ ├── connection.py │ ├── dashboard.py │ ├── defaults │ │ ├── letters.py │ │ ├── prompts.py │ │ └── templates.py │ ├── jobs.py │ ├── letter.py │ ├── patient.py │ ├── rss.py │ ├── templates.py │ └── todo.py ├── demo │ ├── demo_db.py │ └── example_patients.json ├── rag │ ├── __init__.py │ ├── chroma.py │ ├── chunking_utils.py │ ├── fixed_token_chunker.py │ ├── processing.py │ ├── recursive_token_chunker.py │ └── semantic_chunker.py ├── requirements.txt ├── schemas │ ├── __init__.py │ ├── chat.py │ ├── config.py │ ├── dashboard.py │ ├── grammars.py │ ├── letter.py │ ├── patient.py │ ├── rag.py │ └── templates.py ├── server.py ├── tests │ ├── __init__.py │ ├── test_chat.py │ ├── test_config.py │ ├── test_database.py │ ├── test_letter.py │ ├── test_patient.py │ ├── test_rag.py │ ├── test_templates.py │ └── test_transcription.py └── utils │ ├── __init__.py │ ├── adaptive_refinement.py │ ├── chat.py │ ├── document_processing.py │ ├── helpers.py │ ├── letter.py │ ├── llm_client.py │ ├── rss.py │ ├── templates.py │ └── transcription.py └── src ├── App.js ├── components ├── common │ ├── Buttons.js │ ├── FloatingActionMenu.js │ └── Toast.js ├── landing │ ├── DailyAnalysisPanel.js │ ├── NewsDigestPanel.js │ ├── RssFeedPanel.js │ ├── ServerInfoPanel.js │ └── TaskManagerPanel.js ├── modals │ └── ConfirmLeaveModal.js ├── patient │ ├── Chat.js │ ├── Letter.js │ ├── PatientInfoBar.js │ ├── PatientTable.js │ ├── Scribe.js │ ├── ScribeTabs │ │ ├── DocumentUploadTab.js │ │ ├── PreviousVisitTab.js │ │ ├── ReasoningTab.js │ │ ├── RecordingWidget.js │ │ └── VoiceInputTab.js │ ├── Summary.js │ ├── chat │ │ ├── ChatHeader.js │ │ ├── ChatInput.js │ │ ├── ChatMessages.js │ │ ├── ChatPanel.js │ │ ├── ChatSuggestions.js │ │ └── QuickChatButtons.js │ └── letter │ │ ├── CustomInstructionsInput.js │ │ ├── FloatingLetterButton.js │ │ ├── LetterEditor.js │ │ ├── LetterPanel.js │ │ ├── PanelFooterActions.js │ │ ├── RefinementPanel.js │ │ └── TemplateSelector.js ├── rag │ ├── DeleteModal.js │ ├── DocumentExplorer.js │ ├── RagChat.js │ └── Uploader.js ├── settings │ ├── ChatSettingsPanel.js │ ├── LetterTemplatesPanel.js │ ├── ModelSettingsPanel.js │ ├── PromptSettingsPanel.js │ ├── RagSettingsPanel.js │ ├── SettingsActions.js │ ├── TemplateEditor.js │ ├── TemplateSettingsPanel.js │ └── UserSettingsPanel.js └── sidebar │ ├── DeleteConfirmationModal.js │ ├── Sidebar.js │ ├── SidebarHelpers.js │ ├── SidebarNavigation.js │ ├── SidebarPatientList.js │ └── VersionInfo.js ├── index.css ├── index.js ├── pages ├── ClinicSummary.js ├── LandingPage.js ├── OutstandingJobs.js ├── PatientDetails.js ├── Rag.js └── Settings.js ├── theme ├── animations.js ├── colors.js ├── components.js ├── config.js ├── index.js ├── styles │ ├── base.js │ ├── button.js │ ├── checkbox.js │ ├── documentExplorer.js │ ├── floating.js │ ├── index.js │ ├── input.js │ ├── modal.js │ ├── modeSelector.js │ ├── panel.js │ ├── patientInfo.js │ ├── scrollbar.js │ ├── sidebar.js │ ├── tab.js │ └── toggle.js ├── typography.js └── utils.js └── utils ├── api ├── chatApi.js ├── landingApi.js ├── letterApi.js ├── patientApi.js ├── ragApi.js ├── settingsApi.js ├── templateApi.js └── transcriptionApi.js ├── chat ├── chatHandlers.js ├── messageParser.js └── messageUtils.js ├── constants └── index.js ├── helpers ├── apiHelpers.js ├── errorHandlers.js ├── formatHelpers.js ├── loadingHelpers.js ├── processingHelpers.js ├── settingsHelpers.js └── validationHelpers.js ├── hooks ├── UseToastMessage.js ├── useChat.js ├── useCollapse.js ├── useLetter.js ├── useLetterTemplates.js ├── usePatient.js ├── useSettings.js └── useTranscription.js ├── letter └── letterUtils.js ├── patient └── patientHandlers.js ├── services └── templateService.js ├── settings └── settingsUtils.js └── templates ├── templateContext.js └── templateService.js /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = server 3 | omit = 4 | */tests/* 5 | */venv/* 6 | setup.py 7 | 8 | [report] 9 | exclude_lines = 10 | pragma: no cover 11 | def __repr__ 12 | if self.debug: 13 | raise NotImplementedError 14 | if __name__ == .__main__.: 15 | pass 16 | raise ImportError 17 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/__pycache__ 2 | **/*.pyc 3 | **/*.pyo 4 | **/*.pyd 5 | /docs/ 6 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: CI - Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout Repository 17 | uses: actions/checkout@v3 18 | 19 | - name: Build Test Docker Image 20 | run: | 21 | docker build -f Dockerfile.test -t phlox-test . 22 | 23 | - name: Run Tests in Docker Container 24 | run: | 25 | docker run --name test-container phlox-test 26 | docker cp test-container:/usr/src/app/coverage.xml ./coverage.xml 27 | docker cp test-container:/usr/src/app/coverage.lcov ./coverage.lcov 28 | 29 | - name: Coveralls 30 | uses: coverallsapp/github-action@v2 31 | with: 32 | github-token: ${{ secrets.GITHUB_TOKEN }} 33 | file: coverage.lcov 34 | parallel: true 35 | flag-name: run-${{ matrix.python-version }} 36 | 37 | - name: Cleanup 38 | run: docker rm test-container 39 | 40 | finish: 41 | needs: test 42 | if: ${{ always() }} 43 | runs-on: ubuntu-latest 44 | steps: 45 | - name: Coveralls Finished 46 | uses: coverallsapp/github-action@v2 47 | with: 48 | github-token: ${{ secrets.GITHUB_TOKEN }} 49 | parallel-finished: true 50 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | 6 | permissions: 7 | contents: write 8 | pull-requests: write 9 | 10 | name: release-please 11 | 12 | jobs: 13 | release-please: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: googleapis/release-please-action@v4 17 | with: 18 | # this assumes that you have created a personal access token 19 | # (PAT) and configured it as a GitHub action secret named 20 | # `MY_RELEASE_PLEASE_TOKEN` (this secret name is not important). 21 | token: ${{ secrets.RELEASE_PLEASE_TOKEN }} 22 | # this is a built-in strategy in release-please, see "Action Inputs" 23 | # for more options 24 | release-type: node 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # macOS system files 2 | .DS_Store 3 | ._* 4 | 5 | # Datafiles 6 | *.sqlite 7 | data/ 8 | public/CHANGELOG.md 9 | 10 | # Logs 11 | logs/ 12 | *.log 13 | server/logs/ 14 | 15 | # Module Files 16 | node_modules/ 17 | server/node_modules/ 18 | server/venv 19 | 20 | # Environment and IDE settings 21 | .env 22 | .env.development 23 | .env.local 24 | !.env.example 25 | !.env.test 26 | .vscode/ 27 | .idea/ 28 | .zed/ 29 | server/.pytest_cache 30 | oom 31 | 32 | # Python compiled files 33 | .pytest_cache/ 34 | __pycache__/ 35 | *.py[cod] 36 | *$py.class 37 | 38 | # Build directories 39 | build/ 40 | dist/ 41 | .tmp/ 42 | 43 | # Coverage 44 | coverage/ 45 | .coverage 46 | coverage.xml 47 | coverage.lcov 48 | .coveralls.yml 49 | __pycache__/ 50 | *.pyc 51 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build the React app 2 | FROM node:23-slim as build 3 | 4 | # Set the working directory 5 | WORKDIR /usr/src/app 6 | 7 | # Copy package.json and package-lock.json 8 | COPY package*.json ./ 9 | 10 | # Install Node.js dependencies 11 | RUN npm ci 12 | 13 | # Copy the rest of the application 14 | COPY . . 15 | 16 | # Build the React app 17 | RUN npm run build 18 | 19 | # Stage 2: Run the FastAPI app 20 | FROM python:3.12.2-slim 21 | 22 | # Set the working directory 23 | WORKDIR /usr/src/app 24 | 25 | RUN apt-get update && apt-get install -y \ 26 | gcc \ 27 | build-essential \ 28 | tesseract-ocr \ 29 | && rm -rf /var/lib/apt/lists/* 30 | 31 | # Copy the build output and Python server files 32 | COPY --from=build /usr/src/app/build ./build 33 | COPY --from=build /usr/src/app/CHANGELOG.md ./CHANGELOG.md 34 | COPY server/ ./server 35 | RUN mkdir -p /usr/src/app/data 36 | RUN mkdir -p /usr/src/app/static 37 | RUN mkdir -p /usr/src/app/temp 38 | 39 | # Install Python dependencies 40 | RUN pip install --no-cache-dir -r /usr/src/app/server/requirements.txt 41 | 42 | # Expose necessary ports 43 | EXPOSE 5000 44 | 45 | # Define the command to run the FastAPI app 46 | CMD ["uvicorn", "server.server:app", "--host", "0.0.0.0", "--port", "5000"] 47 | -------------------------------------------------------------------------------- /Dockerfile.demo: -------------------------------------------------------------------------------- 1 | # Stage 1: Build the React app 2 | FROM node:23-slim as build 3 | 4 | # Set the working directory 5 | WORKDIR /usr/src/app 6 | 7 | # Copy package.json and package-lock.json 8 | COPY package*.json ./ 9 | 10 | # Install Node.js dependencies 11 | RUN npm ci 12 | 13 | # Copy the rest of the application 14 | COPY . . 15 | 16 | # Build the React app 17 | RUN npm run build 18 | 19 | # Stage 2: Run the FastAPI app 20 | FROM python:3.12.2-slim 21 | 22 | # Set the working directory 23 | WORKDIR /usr/src/app 24 | 25 | RUN apt-get update && apt-get install -y \ 26 | gcc \ 27 | build-essential \ 28 | tesseract-ocr \ 29 | && rm -rf /var/lib/apt/lists/* 30 | 31 | # Copy the build output and Python server files 32 | COPY --from=build /usr/src/app/build ./build 33 | COPY --from=build /usr/src/app/CHANGELOG.md ./CHANGELOG.md 34 | COPY server/ ./server 35 | RUN mkdir -p /usr/src/app/data 36 | RUN mkdir -p /usr/src/app/static 37 | RUN mkdir -p /usr/src/app/temp 38 | 39 | # Install Python dependencies 40 | RUN pip install --no-cache-dir -r /usr/src/app/server/requirements.txt 41 | 42 | # Expose necessary ports 43 | EXPOSE 5000 44 | 45 | # Create a startup script 46 | RUN echo '#!/bin/bash\npython -m server.demo.demo_db\nuvicorn server.server:app --host 0.0.0.0 --port 5000' > /usr/src/app/start.sh 47 | RUN chmod +x /usr/src/app/start.sh 48 | 49 | # Define the command to run the startup script 50 | CMD ["/usr/src/app/start.sh"] 51 | -------------------------------------------------------------------------------- /Dockerfile.dev: -------------------------------------------------------------------------------- 1 | # Use an official Node.js runtime as a parent image 2 | FROM node:23-slim 3 | 4 | # Set the working directory 5 | WORKDIR /usr/src/app 6 | 7 | # Install Python and pip 8 | RUN apt-get update && apt-get install -y python3 python3-pip python3-venv tesseract-ocr libsqlcipher-dev parallel 9 | 10 | # Copy package.json and package-lock.json 11 | COPY package*.json ./ 12 | 13 | # Install Node.js dependencies 14 | RUN npm install 15 | 16 | # Copy the rest of the application 17 | COPY . . 18 | 19 | # Make the application directories 20 | RUN mkdir -p /usr/src/app/data 21 | RUN mkdir -p /usr/src/app/static 22 | RUN mkdir -p /usr/src/app/temp 23 | RUN mkdir -p /usr/src/app/build 24 | 25 | # Create and activate a Python virtual environment, install dependencies 26 | RUN python3 -m venv /usr/src/app/venv 27 | RUN /bin/bash -c "source /usr/src/app/venv/bin/activate && pip install --upgrade pip && pip install --no-cache-dir -r /usr/src/app/server/requirements.txt && pip install --no-cache-dir pytest-asyncio" 28 | 29 | # Make a shell script to initialize the database and start both React and FastAPI 30 | RUN echo '#!/bin/bash\n\ 31 | source /usr/src/app/venv/bin/activate\n\ 32 | python /usr/src/app/server/demo/demo_db.py\n\ 33 | parallel --tag --line-buffer ::: "npm run start-react" "uvicorn server.server:app --reload --host 0.0.0.0 --port 5000"' > start.sh 34 | 35 | # Make the script executable 36 | RUN chmod +x start.sh 37 | 38 | # Expose necessary ports 39 | EXPOSE 3000 40 | #EXPOSE 5000 41 | 42 | # Use the script as the CMD 43 | CMD ["./start.sh"] 44 | -------------------------------------------------------------------------------- /Dockerfile.test: -------------------------------------------------------------------------------- 1 | # Use an official Python runtime as a parent image 2 | FROM python:3.12.2-slim 3 | 4 | # Set the working directory inside the image 5 | WORKDIR /usr/src/app 6 | 7 | # Install system dependencies 8 | RUN apt-get update && apt-get install -y \ 9 | gcc \ 10 | build-essential \ 11 | && rm -rf /var/lib/apt/lists/* 12 | 13 | # First copy requirements to leverage Docker cache 14 | COPY server/requirements.txt /usr/src/app/server/ 15 | RUN pip install --no-cache-dir -r server/requirements.txt 16 | RUN pip install --no-cache-dir pytest pytest-asyncio pytest-cov 17 | 18 | # Then copy the rest of the application 19 | COPY server/ /usr/src/app/server/ 20 | 21 | # Set environment variables 22 | ENV PYTHONPATH=/usr/src/app 23 | ENV TESTING=true 24 | ENV DB_ENCRYPTION_KEY=test_key_12345 25 | 26 | # Create required directories 27 | RUN mkdir -p /usr/src/app/build /usr/src/app/data 28 | RUN echo "Test" > /usr/src/app/build/index.html 29 | 30 | # Create the run_tests.sh script 31 | RUN echo '#!/bin/bash\n\ 32 | # Delete test database if it exists\n\ 33 | rm -f /usr/src/app/data/test_phlox_database.sqlite\n\ 34 | \n\ 35 | PYTHONPATH=/usr/src/app pytest \ 36 | --verbose \ 37 | --cov=server \ 38 | --cov-report=xml \ 39 | --cov-report=lcov \ 40 | --asyncio-mode=strict \ 41 | /usr/src/app/server/tests/\n\ 42 | \n\ 43 | test_exit_code=$?\n\ 44 | \n\ 45 | # Delete test database after tests\n\ 46 | rm -f /usr/src/app/data/test_phlox_database.sqlite\n\ 47 | \n\ 48 | exit $test_exit_code' > /usr/src/app/run_tests.sh 49 | 50 | RUN chmod +x /usr/src/app/run_tests.sh 51 | 52 | # Run tests using the script 53 | ENTRYPOINT ["/usr/src/app/run_tests.sh"] 54 | 55 | RUN chmod +x /usr/src/app/run_tests.sh 56 | 57 | # Run tests using the script 58 | CMD ["/usr/src/app/run_tests.sh"] 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Filipe Gonsalves 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | app: 4 | # Use either build or image, but not both. Uncomment the needed line. 5 | image: localhost/phlox-dev:latest 6 | #build: . 7 | container_name: phlox-dev 8 | network_mode: host 9 | environment: 10 | - DB_ENCRYPTION_KEY=${DB_ENCRYPTION_KEY} 11 | - NODE_ENV=development 12 | - REACT_APP_BACKEND_URL= # Backend URL 13 | - TZ=Australia/Melbourne 14 | volumes: 15 | - ./server:/usr/src/app/server 16 | - ./src:/usr/src/app/src 17 | - ./logs:/usr/src/app/logs 18 | - ./data:/usr/src/app/data # Only for persistent data 19 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | app: 4 | image: phlox:latest 5 | container_name: phlox 6 | ports: 7 | - "5000:5000" 8 | environment: 9 | - DB_ENCRYPTION_KEY= ${DB_ENCRYPTION_KEY} 10 | - REACT_APP_BACKEND_URL= # Replace with your desired URL 11 | - TZ= # Replace with your timezone 12 | volumes: 13 | - ./data:/usr/src/app/data # Only for persistent data 14 | - ./logs:/usr/src/app/logs # Optional: Persist logs 15 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Phlox Documentation 2 | 3 | Welcome to the Phlox documentation! This site provides comprehensive information about Phlox, an open-source patient management and AI assistant solution. 4 | 5 | ## Table of Contents 6 | 7 | - [Overview](./overview.md) 8 | - [Features](#features) 9 | - [Medical Transcription and Note Summarization](./features/transcription.md) 10 | - [Flexible Template System](./features/templates.md) 11 | - [Task Manager](./features/task_manager.md) 12 | - [Correspondence Generation](./features/correspondence.md) 13 | - [AI Features](./features/ai.md) 14 | - [Dashboard with RSS Reader](./features/dashboard.md) 15 | - [Architecture Overview](./architecture.md) 16 | - [Setup and Installation](./setup.md) 17 | - [Warnings, Limitations, and Regulatory Considerations](./warnings.md) 18 | 19 | ## Getting Started 20 | 21 | For a quick start, refer to the [Setup and Installation](./setup.md) guide. To understand the project's philosophy and core concepts, read the [Overview](./overview.md). 22 | 23 | Explore the documentation to learn more about each feature, the system architecture, and how to use Phlox effectively. Remember to carefully review the [Warnings, Limitations, and Regulatory Considerations](./warnings.md) before using Phlox. 24 | -------------------------------------------------------------------------------- /docs/architecture.md: -------------------------------------------------------------------------------- 1 | # Architecture Overview 2 |

3 | Phlox Architecture 4 |

5 | 6 | ## Components 7 | 8 | ### Frontend (React/Chakra UI) 9 | - User interface and interactions 10 | - API calls to backend 11 | - Audio recording and playback 12 | 13 | ### Backend (FastAPI) 14 | - REST API endpoints 15 | - Core application logic 16 | - Integrates with Ollama or any OpenAI compatible endpoint, Whisper, and ChromaDB 17 | - Database operations 18 | 19 | ### Database (SQLite) 20 | - Local file-based storage 21 | - Encrypted via `DB_ENCRYPTION_KEY` 22 | - Stores: 23 | - Patient records 24 | - Clinical notes 25 | - Templates 26 | - Settings 27 | 28 | ### LLM 29 | - Local model inference (or remote if prefered) 30 | - Handles: 31 | - Note generation 32 | - Clinical summaries 33 | - RSS processing 34 | - RAG queries 35 | 36 | ### Transcription 37 | - Compatible with any Whisper endpoint 38 | - Converts audio to text 39 | - Configurable service selection 40 | 41 | #### Transcription Flow 42 | The transcription process involves multiple steps to convert audio into structured clinical notes within the constraints of smaller, locally-hosted models. 43 | 44 | 1. **Audio Recording/Upload** 45 | - Browser records audio or accepts file upload 46 | - Audio sent to backend as WAV format 47 | 48 | 2. **Initial Transcription (Whisper)** 49 | - Audio processed by configured Whisper endpoint 50 | - Returns raw text with timestamps 51 | - Segments combined into single transcript 52 | 53 | 3. **Template Processing (LLM)** 54 | - Transcript broken into template fields to manage context length 55 | - Each field processed concurrently to: 56 | 1. Extract key points as structured JSON 57 | 2. Perform content refinement 58 | 3. Apply formatting 59 | 60 | This staged approach helps smaller models by: 61 | - Breaking large transcripts into manageable chunks given most local models' long-range context limitations 62 | - Using structured JSON to constrain outputs 63 | - Allowing multiple refinement passes with focused prompts 64 | - Reducing hallucination risk through structured extraction 65 | 66 | 4. **Final Assembly** 67 | - Processed fields combined into complete note 68 | - Patient context merged 69 | - Formatting rules applied 70 | - Results returned to frontend 71 | 72 | ### Model Considerations 73 | 74 | - **Output Quality:** Smaller models can hallucinate or lose coherence with long outputs. 75 | 76 | - **Compute Resources:** Async processing of fields improves performance if your backend supports concurrency. Chunking and JSON extraction approach helps maintain structure and accuracy while working within resource constraints. 77 | 78 | - **Refinement Passes:** Multiple focused passes produce better results than single large outputs with smaller models. Adaptive refinement instructions make these passes more effective by incorporating user preferences. 79 | 80 | Example flow for a single field: 81 | ```txt 82 | Audio → Raw Transcription → JSON Extraction → Refinement (style + adaptive rules) → Final Output 83 | ``` 84 | 85 | ### RAG (ChromaDB) 86 | - Vector database for document storage 87 | - Requires a tool calling model to be selected. 88 | - Enables context-aware queries 89 | - Stores medical document embeddings 90 | 91 | ## Data Persistence 92 | - SQLite database and ChromaDB data persisted on host 93 | - Volume mount: `./data:/usr/src/app/data` 94 | - Data preserved across container restarts 95 | -------------------------------------------------------------------------------- /docs/features/ai.md: -------------------------------------------------------------------------------- 1 | # AI Features 2 | 3 | Phlox includes a few ways to chat with documents and clinical notes using LLMs: 4 | 5 | ## Document Chat 6 | Chat with uploaded medical documents and guidelines: 7 | 8 | 1. Upload PDFs to collections in the Document Explorer 9 | 2. Ask questions about the documents 10 | 3. Get responses with citations to specific document sources 11 | 12 |

13 | Document Chat 14 |

15 | 16 | ## Case Chat 17 | Discuss patient cases with the LLM: 18 | 19 | 1. Click chat icon in patient view 20 | 2. Ask questions about the current case 21 | 3. LLM references the clinical note content in responses 22 | 4. The LLM will also make a tool call to the RAG database if required 23 | 24 |

25 | Case Chat 26 |

27 | 28 | ## Clinical Reasoning Assistant 29 | Generate structured clinical analysis for patient encounters: 30 | 31 | 1. After creating a clinical note, click "Generate Reasoning" 32 | 2. The LLM will analyze the case and provide: 33 | - Brief case summary 34 | - Differential diagnoses 35 | - Recommended investigations 36 | - Key clinical considerations 37 | 3. Review the AI's thinking process and suggestions 38 | 4. Use as prompts for further investigation or discussion 39 | 40 | Note: Like all LLM outputs, reasoning suggestions should be verified against clinical judgment and guidelines. At the moment, reasoning does not make tool calls to the RAG database. 41 | 42 |

43 | Reasoning Assistant 44 |

45 | 46 | ## Adaptive Refinement 47 | 48 | Phlox learns your documentation preferences over time to improve note quality: 49 | 50 | 1. Edit generated content in any template field 51 | 2. When you save the note, Phlox compares your edits with the original AI output 52 | 3. The system generates specific refinement instructions based on your changes 53 | 4. Future notes automatically incorporate these preferences 54 | 55 | Note: While adaptive refinement improves stylistic alignment with user preferences over time, it does not reduce the risk of factual errors. **Never assume learned preferences correlate with improved factual accuracy.** 56 | -------------------------------------------------------------------------------- /docs/features/correspondence.md: -------------------------------------------------------------------------------- 1 | # Correspondence Generation 2 | 3 | Generate patient letters from clinical notes using templates and LLM refinement. 4 | 5 | ## Usage 6 | 7 | 1. Click "Generate Correspondence" after creating note 8 | 2. Select template or use custom instructions 9 | 3. Edit or refine letter using LLM suggestions 10 | 4. Copy or save final letter 11 | 12 | ## Features 13 | 14 | - Templates for standardized letter formats 15 | - Interactive refinement with LLM 16 | - Automatic token management for context length 17 | - Save letters for reference 18 | 19 | ## Templates 20 | 21 | Create and manage letter templates in Settings: 22 | 23 | - Default templates included 24 | - Custom templates with specific instructions 25 | - Set default template for quick generation 26 | 27 |

28 | Letter Generation 29 |

30 | -------------------------------------------------------------------------------- /docs/features/dashboard.md: -------------------------------------------------------------------------------- 1 | # Dashboard with RSS Reader 2 | 3 | The dashboard includes a simple RSS reader that automatically generates summaries of medical articles using an LLM. 4 | 5 | ## Usage 6 | 7 | 1. Add RSS feeds in the dashboard 8 | 2. Summaries are generated overnight (requires instance to be running) 9 | 3. View summaries and article links in the dashboard 10 | 11 | Note: The UI may be unresponsive during summary generation since LLM calls here are currently blocking. 12 | 13 | ## Tip 14 | 15 | Make sure you subscribe only to reputable medical/clinical RSS feeds since summary quality depends on the source material. 16 | 17 |

18 | Dashboard 19 |

20 | -------------------------------------------------------------------------------- /docs/features/task_manager.md: -------------------------------------------------------------------------------- 1 | # Task Manager 2 | 3 | The task manager automatically extracts action items from clinical notes and tracks their completion status. 4 | 5 | Phlox also has a basic ToDo feature for other tasks. This appear in the dashboard. 6 | 7 | ## Usage 8 | 9 | 1. Tasks are automatically created from numbered items in the Plan section of notes: 10 | ``` 11 | Plan: 12 | 1. Order blood tests 13 | 2. Follow up in 2 weeks 14 | ``` 15 | 16 | 2. View tasks: 17 | - Per clinic day: Go to "Clinic Summary" 18 | - All outstanding tasks: Go to "Outstanding Tasks" 19 | 20 | 3. Mark tasks complete by clicking checkboxes 21 | 22 | No setup needed - tasks are auto-generated when saving notes. The sidebar shows a count of incomplete tasks. 23 | 24 |

25 | Task Manager 26 |

27 | -------------------------------------------------------------------------------- /docs/features/templates.md: -------------------------------------------------------------------------------- 1 | # Flexible Template System 2 | 3 | Phlox includes a template system for structuring clinical notes. Templates consist of fields that can be customized with prompts, formatting rules, and persistence settings 4 | 5 | ## Features 6 | 7 | - Create and customize templates with configurable fields 8 | - Version control for tracking template changes 9 | - Generate templates automatically from example notes 10 | - Share templates between users 11 | 12 | ## Template Fields 13 | 14 | Fields can be configured with: 15 | 16 | - System prompts to guide AI output 17 | - Format specifications (bullets, numbers, narrative) 18 | - Persistence flags to carry forward information 19 | - Required/optional status 20 | - Post-processing refinement rules 21 | 22 | ## Using Templates 23 | 24 | ### Create Template 25 | 1. Go to "Templates" section 26 | 2. Click "Create New Template" 27 | 3. Add and configure fields 28 | 4. Save template 29 | 30 | ### Generate from Example 31 | 1. Select "Generate Template from Example" 32 | 2. Paste in example note 33 | 3. Review and adjust generated template 34 | 35 | ### Apply Template 36 | 1. Choose template when creating note 37 | 2. Template fields guide AI generation 38 | 3. Review and edit content 39 | -------------------------------------------------------------------------------- /docs/features/transcription.md: -------------------------------------------------------------------------------- 1 | # Medical Transcription 2 | 3 | Phlox converts audio recordings into structured clinical notes. 4 | 5 | ## Usage 6 | 7 | 1. **Record Audio/Upload Files** 8 | - Use in-browser recording or upload audio files 9 | - Upload documents (PDF, Word, images) or paste text content 10 | 11 | 2. **Generate Note** 12 | - Audio is transcribed using Whisper 13 | - LLM processes transcript into structured note based on selected template 14 | 15 | 3. **Review & Save** 16 | - Edit generated note content 17 | - Copy to EMR or save locally 18 | 19 | ## Features 20 | 21 | - Live audio recording with pause/resume 22 | - File upload support 23 | - Template-based note structuring 24 | - Field-specific dictation 25 | - View previous visit summaries 26 | - Progress tracking during processing 27 | -------------------------------------------------------------------------------- /docs/images/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloodworks-io/phlox/7bc351cb51aa0c7ccabd6547cdbe3a66e1ec8da6/docs/images/architecture.png -------------------------------------------------------------------------------- /docs/images/chat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloodworks-io/phlox/7bc351cb51aa0c7ccabd6547cdbe3a66e1ec8da6/docs/images/chat.png -------------------------------------------------------------------------------- /docs/images/correspondence.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloodworks-io/phlox/7bc351cb51aa0c7ccabd6547cdbe3a66e1ec8da6/docs/images/correspondence.png -------------------------------------------------------------------------------- /docs/images/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloodworks-io/phlox/7bc351cb51aa0c7ccabd6547cdbe3a66e1ec8da6/docs/images/dashboard.png -------------------------------------------------------------------------------- /docs/images/documents.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloodworks-io/phlox/7bc351cb51aa0c7ccabd6547cdbe3a66e1ec8da6/docs/images/documents.png -------------------------------------------------------------------------------- /docs/images/jobs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloodworks-io/phlox/7bc351cb51aa0c7ccabd6547cdbe3a66e1ec8da6/docs/images/jobs.png -------------------------------------------------------------------------------- /docs/images/readme_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloodworks-io/phlox/7bc351cb51aa0c7ccabd6547cdbe3a66e1ec8da6/docs/images/readme_logo.png -------------------------------------------------------------------------------- /docs/images/readme_logo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloodworks-io/phlox/7bc351cb51aa0c7ccabd6547cdbe3a66e1ec8da6/docs/images/readme_logo.webp -------------------------------------------------------------------------------- /docs/images/reasoning.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloodworks-io/phlox/7bc351cb51aa0c7ccabd6547cdbe3a66e1ec8da6/docs/images/reasoning.png -------------------------------------------------------------------------------- /docs/images/transcription.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloodworks-io/phlox/7bc351cb51aa0c7ccabd6547cdbe3a66e1ec8da6/docs/images/transcription.png -------------------------------------------------------------------------------- /docs/overview.md: -------------------------------------------------------------------------------- 1 | # Phlox Overview 2 | 3 | ## What is Phlox? 4 | 5 | Phlox is an open-source, local-first clinical tool with the following features: 6 | 7 | - **Patient Records:** Basic database for patient demographics and history 8 | - **Medical Transcription:** Uses Whisper + Ollama to convert audio to structured notes 9 | - **Task Management:** Extracts action items from clinical notes 10 | - **RSS Reader:** Aggregates and summarizes medical news using LLMs 11 | - **AI Assistant:** RAG system using ChromaDB for case discussions with reference to medical documents. Reasoning model interface. 12 | 13 | ## Design 14 | 15 | - Runs locally on standard hardware 16 | - Customizable templates and LLM settings 17 | - All data stays on your machine 18 | 19 | ## Philosophy 20 | 21 | The core idea is to use LLMs to expand clinical consideration sets by: 22 | - Surfacing relevant information from guidelines and journals 23 | - Automating documentation tasks 24 | - Supporting differential diagnosis discussions 25 | 26 | ## Important Caveats 27 | 28 | - LLMs can hallucinate plausible but incorrect information 29 | - Verification against primary medical sources is mandatory 30 | - Clinical judgment remains supreme 31 | - Models can lead reasoning down convincing but incorrect paths 32 | 33 | This is an experimental tool designed to assist, not replace, clinical decision-making. 34 | -------------------------------------------------------------------------------- /docs/warnings.md: -------------------------------------------------------------------------------- 1 | # Warnings, Limitations, and Regulatory Considerations 2 | 3 | **It is crucial to understand the limitations and potential risks associated with using Phlox.** This project is provided for **educational and research purposes only.** **It is NOT intended for direct clinical use in its current state without rigorous validation, security hardening, and adherence to all applicable regulations.** 4 | 5 | ## Usage Warnings and Disclaimers 6 | 7 | 1. **Experimental Software:** Phlox is an experimental, personal project. The code is under active development and may contain bugs, inconsistencies, and security vulnerabilities. It has not undergone formal testing or quality assurance processes expected of medical devices. 8 | 9 | 2. **AI Hallucinations and Inaccuracies:** Phlox relies on Large Language Models (LLMs) for various functions. **LLMs are known to hallucinate,** meaning they can generate outputs that are plausible but factually incorrect or nonsensical. **This is especially true for smaller, locally-run models.** 10 | 11 | - **Critical Verification:** **ALL AI-generated outputs from Phlox (clinical notes, summaries, correspondence, decision support suggestions, etc.) MUST be independently verified by qualified medical professionals using trusted, primary sources.** Do not rely solely on AI-generated information for clinical decision-making. 12 | - **Risk of Misinformation:** AI-generated content may contain inaccurate medical information, incorrect diagnoses, or inappropriate treatment recommendations. **Using unverified AI output in clinical practice could lead to patient harm.** 13 | 14 | 3. **Not a Certified Medical Device:** Phlox is **not a certified medical device** and has not been evaluated or approved by any regulatory bodies (e.g., FDA, TGA, MHRA, etc.). It most likely does not meet the regulatory requirements for medical devices in any jurisdiction. 15 | 16 | 4. **No Regulatory Compliance (HIPAA, GDPR, TGA, etc.):** Phlox, in its default configuration, **does not comply with regulations such as HIPAA, GDPR, or TGA regulations, or any other patient data privacy or medical device regulations.** 17 | 18 | - **Data Security and Privacy:** Phlox lacks advanced security features, user authentication, audit logs, and data access controls required for regulatory compliance. 19 | - **Database Encryption:** While basic database encryption, this is not a comprehensive security measure. 20 | - **Transcription Data:** Consider the privacy implications of your chosen Whisper transcription service, especially if using cloud-based APIs. 21 | 22 | 5. **No User Authentication/Authorization:** Phlox currently lacks user authentication and access controls. **Exposing Phlox to the public internet is highly discouraged and poses a significant security risk.** Anyone with network access to your Phlox instance can potentially access all patient data. 23 | 24 | 6. **Intended Use - Educational and Personal:** Phlox is primarily intended for: 25 | - **Educational purposes:** To explore the potential of AI in clinical workflows and learn about LLMs and related technologies. 26 | - **Personal use:** For experimentation, research, and non-clinical exploration of AI-assisted note-taking and information management. 27 | - **Development and Research:** As a platform for further development, research, and contribution to open-source medical AI tools. 28 | 29 | 30 | **By using Phlox, you acknowledge and accept these warnings and limitations. You assume full responsibility for any use of Phlox and agree to use it ethically, responsibly, and in compliance with all applicable laws and regulations.** 31 | 32 | **If you are unsure about any aspect of these warnings or the regulatory implications of using Phlox, DO NOT USE IT in a clinical setting without obtaining professional advice and implementing all necessary safeguards.** 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Phlox", 3 | "version": "0.7.1", 4 | "private": true, 5 | "proxy": "http://localhost:5000", 6 | "dependencies": { 7 | "@chakra-ui/icons": "^2.1.1", 8 | "@chakra-ui/react": "^2.8.2", 9 | "@emotion/react": "^11.11.4", 10 | "@emotion/styled": "^11.11.5", 11 | "framer-motion": "^11.2.10", 12 | "js-tiktoken": "^1.0.12", 13 | "react": "^18.3.1", 14 | "react-dom": "^18.3.1", 15 | "react-icons": "^5.2.1", 16 | "react-markdown": "^8.0.0", 17 | "react-router-dom": "^6.23.1", 18 | "react-scripts": "5.0.1", 19 | "react-textarea-autosize": "^8.5.3" 20 | }, 21 | "scripts": { 22 | "start": "pm2 start server/server.js --name phlox-server", 23 | "start-react": "react-scripts start", 24 | "start-server": "nodemon --ignore '/usr/src/app/venv/*' server/server.js", 25 | "build": "react-scripts build", 26 | "test": "react-scripts test", 27 | "eject": "react-scripts eject" 28 | }, 29 | "eslintConfig": { 30 | "extends": [ 31 | "react-app", 32 | "react-app/jest" 33 | ] 34 | }, 35 | "browserslist": { 36 | "production": [ 37 | ">0.2%", 38 | "not dead", 39 | "not op_mini all" 40 | ], 41 | "development": [ 42 | "last 1 chrome version", 43 | "last 1 firefox version", 44 | "last 1 safari version" 45 | ] 46 | }, 47 | "description": "This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).", 48 | "main": "index.js", 49 | "keywords": [], 50 | "author": "", 51 | "license": "ISC", 52 | "devDependencies": { 53 | "eslint-config-react-app": "^7.0.1", 54 | "nodemon": "^2.0.7" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloodworks-io/phlox/7bc351cb51aa0c7ccabd6547cdbe3a66e1ec8da6/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloodworks-io/phlox/7bc351cb51aa0c7ccabd6547cdbe3a66e1ec8da6/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloodworks-io/phlox/7bc351cb51aa0c7ccabd6547cdbe3a66e1ec8da6/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloodworks-io/phlox/7bc351cb51aa0c7ccabd6547cdbe3a66e1ec8da6/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloodworks-io/phlox/7bc351cb51aa0c7ccabd6547cdbe3a66e1ec8da6/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloodworks-io/phlox/7bc351cb51aa0c7ccabd6547cdbe3a66e1ec8da6/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 21 | 25 | 26 | 30 | 34 | Phlox 35 | 36 | 37 | 38 |
39 | 48 | 49 | -------------------------------------------------------------------------------- /public/logo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloodworks-io/phlox/7bc351cb51aa0c7ccabd6547cdbe3a66e1ec8da6/public/logo.webp -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /server/__init__.py: -------------------------------------------------------------------------------- 1 | # init -------------------------------------------------------------------------------- /server/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloodworks-io/phlox/7bc351cb51aa0c7ccabd6547cdbe3a66e1ec8da6/server/api/__init__.py -------------------------------------------------------------------------------- /server/api/chat.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends 2 | from fastapi.exceptions import HTTPException 3 | from fastapi.responses import StreamingResponse 4 | from server.utils.chat import ChatEngine 5 | from server.schemas.chat import ChatRequest, ChatResponse 6 | import logging 7 | import json 8 | router = APIRouter() 9 | 10 | 11 | def _get_chat_engine(): 12 | return ChatEngine() 13 | 14 | 15 | @router.post("", response_model=ChatResponse) 16 | async def chat( 17 | chat_request: ChatRequest, 18 | chat_engine: ChatEngine = Depends(_get_chat_engine), 19 | ): 20 | """ 21 | Process a chat request and return a streaming response. 22 | 23 | This endpoint accepts a chat request containing a conversation history and uses 24 | ChatEngine to generate responses asynchronously. The response chunks are streamed as 25 | Server Side Events. 26 | 27 | Args: 28 | chat_request (ChatRequest): The incoming chat request containing chat messages. 29 | chat_engine (ChatEngine): The chat engine used to process the chat request. 30 | 31 | Returns: 32 | StreamingResponse: An SSE streaming response that yields response chunks 33 | formatted as JSON with the prefix "data: " and separated by newlines. 34 | 35 | Raises: 36 | HTTPException: If an error occurs during processing, a 500 error is raised with details. 37 | """ 38 | try: 39 | logging.info("Received chat request") 40 | logging.debug(f"Chat request: {chat_request}") 41 | 42 | conversation_history = chat_request.messages 43 | raw_transcription = chat_request.raw_transcription 44 | 45 | async def generate(): 46 | chunk_count = 0 47 | async for chunk in chat_engine.stream_chat(conversation_history, raw_transcription=raw_transcription ): 48 | chunk_count += 1 49 | yield f"data: {json.dumps(chunk)}\n\n" 50 | 51 | return StreamingResponse( 52 | generate(), 53 | media_type="text/event-stream" 54 | ) 55 | except Exception as e: 56 | logging.error(f"An error occurred: {e}") 57 | raise HTTPException(status_code=500, detail=str(e)) 58 | -------------------------------------------------------------------------------- /server/database/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /server/database/dashboard.py: -------------------------------------------------------------------------------- 1 | from server.database.connection import PatientDatabase 2 | import logging 3 | 4 | db = PatientDatabase() 5 | 6 | 7 | def add_rss_feed(url: str, title: str): 8 | """Adds an RSS feed to the database.""" 9 | db.cursor.execute( 10 | "INSERT INTO rss_feeds (url, title) VALUES (?, ?)", (url, title) 11 | ) 12 | db.commit() 13 | 14 | 15 | def get_rss_feeds(): 16 | """Retrieves all RSS feeds from the database.""" 17 | db.cursor.execute("SELECT * FROM rss_feeds") 18 | return [dict(row) for row in db.cursor.fetchall()] 19 | 20 | 21 | def add_todo(task: str): 22 | """Adds a to-do item to the database.""" 23 | db.cursor.execute("INSERT INTO todos (task) VALUES (?)", (task,)) 24 | db.commit() 25 | 26 | 27 | def get_todos(): 28 | """Retrieves all to-do items from the database.""" 29 | db.cursor.execute("SELECT * FROM todos") 30 | return [dict(row) for row in db.cursor.fetchall()] 31 | 32 | 33 | def add_rss_item( 34 | feed_id: int, 35 | title: str, 36 | link: str, 37 | description: str, 38 | published: str, 39 | digest: str, 40 | ): 41 | """Adds an RSS item to the database.""" 42 | db.cursor.execute( 43 | "INSERT INTO rss_items (feed_id, title, link, description, published, digest) VALUES (?, ?, ?, ?, ?, ?)", 44 | (feed_id, title, link, description, published, digest), 45 | ) 46 | db.commit() 47 | 48 | 49 | def get_rss_items(feed_id: int = None): 50 | """Retrieves RSS items from the database, optionally filtered by feed ID.""" 51 | if feed_id: 52 | db.cursor.execute( 53 | "SELECT * FROM rss_items WHERE feed_id = ?", (feed_id,) 54 | ) 55 | else: 56 | db.cursor.execute("SELECT * FROM rss_items") 57 | return [dict(row) for row in db.cursor.fetchall()] 58 | 59 | 60 | def get_patients_with_outstanding_jobs_and_summaries(): 61 | """Retrieves patients with outstanding jobs and their summaries.""" 62 | try: 63 | db.cursor.execute( 64 | """ 65 | SELECT id, encounter_summary, encounter_date 66 | FROM patients 67 | WHERE all_jobs_completed = 0 68 | """ 69 | ) 70 | rows = db.cursor.fetchall() 71 | patients_data = [dict(row) for row in rows] 72 | return patients_data 73 | except Exception as e: 74 | logging.error(f"Database error fetching patient data: {e}") 75 | raise 76 | -------------------------------------------------------------------------------- /server/database/defaults/letters.py: -------------------------------------------------------------------------------- 1 | class DefaultLetters: 2 | @staticmethod 3 | def get_default_letter_templates(): 4 | return [ 5 | (1, 'GP Letter', 'Write a brief letter to the patient\'s general practitioner...'), 6 | (2, 'Specialist Referral', 'Write a detailed referral letter...'), 7 | (3, 'Discharge Summary', 'Write a comprehensive discharge summary...'), 8 | (4, 'Brief Update', 'Write a short update letter...') 9 | ] 10 | -------------------------------------------------------------------------------- /server/database/defaults/prompts.py: -------------------------------------------------------------------------------- 1 | DEFAULT_PROMPTS = { 2 | "prompts": { 3 | "refinement": { 4 | "system": "You are an editing assistant. The user will send you a summary with which you will perform the following:\n1. Remove any phrases like 'doctor says' or 'patient says'.\n2. Brevity is key. For example, replace 'Patient feels tired', with 'feels tired'; instead of \"Follow-up appointment to review blood tests in 6 months time\" just say \"Review in 6 months with bloods\"\n3. Avoid using phrases like 'the doctor' or 'the patient'.\n4. Do not change the formatting of the input. It must remain in dot points, numbered list, narrative prose, or whatever format it was initially provided in.\n5. Use Australian medical abbreviations where possible.\n\nThe summary you provide will be for the doctor's own records." 5 | }, 6 | "chat": { 7 | "system": "You are a helpful physician's assistant. You provide, brief, and to the point responses to the doctor's questions in American English. Maintain a professional tone. Try to keep your responses to less than 2 paragraphs. The doctor will send their notes from the most recent encounter to start." 8 | }, 9 | "summary": { 10 | "system": "Summarize the patient's condition in a single, concise sentence. Start with the patient's age and gender, then briefly mention their main medical condition or reason for visit. Do not list multiple conditions. Focus on the most significant aspect. Example format: \"52 year old male with a history of unprovoked pulmonary embolisms (PEs) presents for follow-up and management\" Keep your response under 20 words. Do not use newlines or colons in your response." 11 | }, 12 | "letter": { 13 | "system": "You are a professional medical correspondence writer. The user is a specialist physician; they will give you a medical consultation note. You are to convert it into a brief correspondence for another health professional. The tone should be friendly. Put your response in a code block using triple backticks." 14 | }, 15 | "reasoning": { 16 | "system": "You are an expert medical reasoning assistant. Analyze clinical cases thoroughly and provide structured insights on differentials, investigations, and key considerations. Focus on providing actionable clinical insights." 17 | } 18 | }, 19 | "options": { 20 | "chat": { 21 | "temperature": 0.1, 22 | "num_ctx": 7168 23 | }, 24 | "general": { 25 | "temperature": 0.1, 26 | "num_ctx": 7168, 27 | "stop": ["\n\n"] 28 | }, 29 | "letter": { 30 | "temperature": 0.6, 31 | "num_ctx": 7168, 32 | "stop": ["```"] 33 | }, 34 | "secondary": { 35 | "temperature": 0.1, 36 | "num_ctx": 1024 37 | }, 38 | "reasoning": { 39 | "temperature": 0.1, 40 | "num_ctx": 4096 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /server/database/todo.py: -------------------------------------------------------------------------------- 1 | from server.database.connection import PatientDatabase 2 | from typing import List, Dict 3 | from icalendar import Calendar, Todo 4 | from datetime import datetime 5 | import os 6 | 7 | db = PatientDatabase() 8 | ICS_FILE_PATH = "/usr/src/app/data/todos.ics" 9 | 10 | 11 | def update_ics_file(): 12 | cal = Calendar() 13 | todos = get_todo_items() 14 | for todo in todos: 15 | vtodo = Todo() 16 | vtodo.add("summary", todo["task"]) 17 | vtodo.add("dtstamp", datetime.now()) 18 | vtodo.add( 19 | "status", "COMPLETED" if todo["completed"] else "NEEDS-ACTION" 20 | ) 21 | vtodo["uid"] = f"todo-{todo['id']}@example.com" 22 | cal.add_component(vtodo) 23 | 24 | with open(ICS_FILE_PATH, "wb") as f: 25 | f.write(cal.to_ical()) 26 | 27 | 28 | def add_todo_item(task: str) -> Dict: 29 | db.cursor.execute( 30 | "INSERT INTO todos (task, completed) VALUES (?, ?)", (task, False) 31 | ) 32 | todo_id = db.cursor.lastrowid 33 | db.commit() 34 | update_ics_file() 35 | return {"id": todo_id, "task": task, "completed": False} 36 | 37 | 38 | def get_todo_items() -> List[Dict]: 39 | db.cursor.execute("SELECT id, task, completed FROM todos") 40 | todos = [ 41 | {"id": row[0], "task": row[1], "completed": bool(row[2])} 42 | for row in db.cursor.fetchall() 43 | ] 44 | return todos 45 | 46 | 47 | def update_todo_item(todo_id: int, task: str, completed: bool) -> Dict: 48 | db.cursor.execute( 49 | "UPDATE todos SET task = ?, completed = ? WHERE id = ?", 50 | (task, completed, todo_id), 51 | ) 52 | db.commit() 53 | update_ics_file() 54 | return {"id": todo_id, "task": task, "completed": completed} 55 | 56 | 57 | def delete_todo_item(todo_id: int): 58 | db.cursor.execute("DELETE FROM todos WHERE id = ?", (todo_id,)) 59 | db.commit() 60 | update_ics_file() 61 | 62 | 63 | # Ensure the data directory exists 64 | os.makedirs(os.path.dirname(ICS_FILE_PATH), exist_ok=True) 65 | 66 | # Initialize the ICS file if it doesn't exist 67 | if not os.path.exists(ICS_FILE_PATH): 68 | update_ics_file() 69 | -------------------------------------------------------------------------------- /server/rag/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloodworks-io/phlox/7bc351cb51aa0c7ccabd6547cdbe3a66e1ec8da6/server/rag/__init__.py -------------------------------------------------------------------------------- /server/rag/chunking_utils.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | import re 3 | from fuzzywuzzy import fuzz, process 4 | import os 5 | from chromadb.utils import embedding_functions 6 | import tiktoken 7 | from abc import ABC, abstractmethod 8 | from typing import List 9 | 10 | class BaseChunker(ABC): 11 | @abstractmethod 12 | def split_text(self, text: str) -> List[str]: 13 | pass 14 | 15 | def find_query_despite_whitespace(document, query): 16 | 17 | # Normalize spaces and newlines in the query 18 | normalized_query = re.sub(r'\s+', ' ', query).strip() 19 | 20 | # Create a regex pattern from the normalized query to match any whitespace characters between words 21 | pattern = r'\s*'.join(re.escape(word) for word in normalized_query.split()) 22 | 23 | # Compile the regex to ignore case and search for it in the document 24 | regex = re.compile(pattern, re.IGNORECASE) 25 | match = regex.search(document) 26 | 27 | if match: 28 | return document[match.start(): match.end()], match.start(), match.end() 29 | else: 30 | return None 31 | 32 | def rigorous_document_search(document: str, target: str): 33 | """ 34 | This function performs a rigorous search of a target string within a document. 35 | It handles issues related to whitespace, changes in grammar, and other minor text alterations. 36 | The function first checks for an exact match of the target in the document. 37 | If no exact match is found, it performs a raw search that accounts for variations in whitespace. 38 | If the raw search also fails, it splits the document into sentences and uses fuzzy matching 39 | to find the sentence that best matches the target. 40 | 41 | Args: 42 | document (str): The document in which to search for the target. 43 | target (str): The string to search for within the document. 44 | 45 | Returns: 46 | tuple: A tuple containing the best match found in the document, its start index, and its end index. 47 | If no match is found, returns None. 48 | """ 49 | if target.endswith('.'): 50 | target = target[:-1] 51 | 52 | if target in document: 53 | start_index = document.find(target) 54 | end_index = start_index + len(target) 55 | return target, start_index, end_index 56 | else: 57 | raw_search = find_query_despite_whitespace(document, target) 58 | if raw_search is not None: 59 | return raw_search 60 | 61 | # Split the text into sentences 62 | sentences = re.split(r'[.!?]\s*|\n', document) 63 | 64 | # Find the sentence that matches the query best 65 | best_match = process.extractOne(target, sentences, scorer=fuzz.token_sort_ratio) 66 | 67 | if best_match[1] < 98: 68 | return None 69 | 70 | reference = best_match[0] 71 | 72 | start_index = document.find(reference) 73 | end_index = start_index + len(reference) 74 | 75 | return reference, start_index, end_index 76 | 77 | def get_openai_embedding_function(): 78 | openai_api_key = os.getenv('OPENAI_API_KEY') 79 | if openai_api_key is None: 80 | raise ValueError("You need to set an embedding function or set an OPENAI_API_KEY environment variable.") 81 | embedding_function = embedding_functions.OpenAIEmbeddingFunction( 82 | api_key=os.getenv('OPENAI_API_KEY'), 83 | model_name="text-embedding-3-large" 84 | ) 85 | return embedding_function 86 | 87 | # Count the number of tokens in each page_content 88 | def openai_token_count(string: str) -> int: 89 | """Returns the number of tokens in a text string.""" 90 | encoding = tiktoken.get_encoding("cl100k_base") 91 | num_tokens = len(encoding.encode(string, disallowed_special=())) 92 | return num_tokens 93 | 94 | class Language(str, Enum): 95 | """Enum of the programming languages.""" 96 | 97 | CPP = "cpp" 98 | GO = "go" 99 | JAVA = "java" 100 | KOTLIN = "kotlin" 101 | JS = "js" 102 | TS = "ts" 103 | PHP = "php" 104 | PROTO = "proto" 105 | PYTHON = "python" 106 | RST = "rst" 107 | RUBY = "ruby" 108 | RUST = "rust" 109 | SCALA = "scala" 110 | SWIFT = "swift" 111 | MARKDOWN = "markdown" 112 | LATEX = "latex" 113 | HTML = "html" 114 | SOL = "sol" 115 | CSHARP = "csharp" 116 | COBOL = "cobol" 117 | C = "c" 118 | LUA = "lua" 119 | PERL = "perl" 120 | -------------------------------------------------------------------------------- /server/rag/semantic_chunker.py: -------------------------------------------------------------------------------- 1 | # Taken from https://research.trychroma.com/evaluating-chunking 2 | # 3 | from typing import List 4 | import numpy as np 5 | from .recursive_token_chunker import RecursiveTokenChunker 6 | from .chunking_utils import get_openai_embedding_function, openai_token_count 7 | from .chunking_utils import BaseChunker 8 | 9 | 10 | class ClusterSemanticChunker(BaseChunker): 11 | def __init__( 12 | self, 13 | embedding_function=None, 14 | max_chunk_size=400, 15 | min_chunk_size=50, 16 | length_function=openai_token_count, 17 | ): 18 | self.splitter = RecursiveTokenChunker( 19 | chunk_size=min_chunk_size, 20 | chunk_overlap=0, 21 | length_function=openai_token_count, 22 | separators=["\n\n", "\n", ".", "?", "!", " ", ""], 23 | ) 24 | 25 | if embedding_function is None: 26 | embedding_function = get_openai_embedding_function() 27 | self._chunk_size = max_chunk_size 28 | self.max_cluster = max_chunk_size // min_chunk_size 29 | self.embedding_function = embedding_function 30 | 31 | def _get_similarity_matrix(self, embedding_function, sentences): 32 | BATCH_SIZE = 500 33 | N = len(sentences) 34 | embedding_matrix = None 35 | 36 | for i in range(0, N, BATCH_SIZE): 37 | batch_sentences = sentences[i : i + BATCH_SIZE] 38 | embeddings = embedding_function(batch_sentences) 39 | 40 | # Convert embeddings list of lists to numpy array 41 | batch_embedding_matrix = np.array(embeddings) 42 | 43 | # Append the batch embedding matrix to the main embedding matrix 44 | if embedding_matrix is None: 45 | embedding_matrix = batch_embedding_matrix 46 | else: 47 | embedding_matrix = np.concatenate( 48 | (embedding_matrix, batch_embedding_matrix), axis=0 49 | ) 50 | 51 | similarity_matrix = np.dot(embedding_matrix, embedding_matrix.T) 52 | 53 | return similarity_matrix 54 | 55 | def _calculate_reward(self, matrix, start, end): 56 | sub_matrix = matrix[start : end + 1, start : end + 1] 57 | return np.sum(sub_matrix) 58 | 59 | def _optimal_segmentation(self, matrix, max_cluster_size, window_size=3): 60 | mean_value = np.mean(matrix[np.triu_indices(matrix.shape[0], k=1)]) 61 | matrix = matrix - mean_value # Normalize the matrix 62 | np.fill_diagonal( 63 | matrix, 0 64 | ) # Set diagonal to 1 to avoid trivial solutions 65 | 66 | n = matrix.shape[0] 67 | dp = np.zeros(n) 68 | segmentation = np.zeros(n, dtype=int) 69 | 70 | for i in range(n): 71 | for size in range(1, max_cluster_size + 1): 72 | if i - size + 1 >= 0: 73 | # local_density = calculate_local_density(matrix, i, window_size) 74 | reward = self._calculate_reward(matrix, i - size + 1, i) 75 | # Adjust reward based on local density 76 | adjusted_reward = reward 77 | if i - size >= 0: 78 | adjusted_reward += dp[i - size] 79 | if adjusted_reward > dp[i]: 80 | dp[i] = adjusted_reward 81 | segmentation[i] = i - size + 1 82 | 83 | clusters = [] 84 | i = n - 1 85 | while i >= 0: 86 | start = segmentation[i] 87 | clusters.append((start, i)) 88 | i = start - 1 89 | 90 | clusters.reverse() 91 | return clusters 92 | 93 | def split_text(self, text: str) -> List[str]: 94 | sentences = self.splitter.split_text(text) 95 | print(self.embedding_function) 96 | similarity_matrix = self._get_similarity_matrix( 97 | self.embedding_function, sentences 98 | ) 99 | 100 | clusters = self._optimal_segmentation( 101 | similarity_matrix, max_cluster_size=self.max_cluster 102 | ) 103 | 104 | docs = [" ".join(sentences[start : end + 1]) for start, end in clusters] 105 | 106 | return docs 107 | -------------------------------------------------------------------------------- /server/requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.10.11 2 | APScheduler==3.10.4 3 | attr==0.3.2 4 | chromadb==0.6.3 5 | fastapi==0.115.8 6 | feedparser==6.0.11 7 | fuzzywuzzy==0.18.0 8 | httpx==0.28.1 9 | icalendar==5.0.13 10 | ollama==0.4.7 11 | openai==1.76.2 12 | Pillow==11.1.0 13 | pydantic==2.10.6 14 | PyMuPDF==1.24.9 15 | pytesseract==0.3.13 16 | pytest==8.3.4 17 | python-dotenv==1.0.1 18 | python-Levenshtein==0.25.1 19 | python-multipart==0.0.18 20 | sqlcipher3_binary==0.5.4 21 | tiktoken==0.7.0 22 | uvicorn==0.34.0 23 | -------------------------------------------------------------------------------- /server/schemas/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloodworks-io/phlox/7bc351cb51aa0c7ccabd6547cdbe3a66e1ec8da6/server/schemas/__init__.py -------------------------------------------------------------------------------- /server/schemas/chat.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | from typing import List, Optional 3 | 4 | class Message(BaseModel): 5 | """ 6 | Represents a single message in a chat conversation. 7 | 8 | Attributes: 9 | role (str): The role of the message sender (e.g., 'user', 'assistant'). 10 | content (str): The content of the message. 11 | """ 12 | role: str 13 | content: str 14 | 15 | class ChatRequest(BaseModel): 16 | """ 17 | Represents a request for a chat interaction. 18 | 19 | Attributes: 20 | messages (List[dict]): A list of message dictionaries, each containing 'role' and 'content'. 21 | raw_transcription (Optional[str]): Raw transcription data, if available. 22 | """ 23 | messages: List[dict] 24 | raw_transcription: Optional[str] = None 25 | 26 | class ChatResponse(BaseModel): 27 | """ 28 | Represents the response from a chat interaction. 29 | 30 | Attributes: 31 | message (str): The response message content. 32 | context (dict, optional): Additional context information, if any. 33 | """ 34 | message: str 35 | context: dict = None 36 | -------------------------------------------------------------------------------- /server/schemas/config.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class Config(BaseModel): 5 | """ 6 | Configuration model for the application. 7 | 8 | Attributes: 9 | OLLAMA_BASE_URL (str): The base URL for the Ollama service. 10 | OLLAMA_MODEL (str): The name of the Ollama model to use. 11 | summaryPrompt (str): The prompt template for generating summaries. 12 | summaryOptions (dict): Additional options for summary generation. 13 | """ 14 | 15 | OLLAMA_BASE_URL: str 16 | OLLAMA_MODEL: str 17 | summaryPrompt: str 18 | summaryOptions: dict 19 | 20 | 21 | class ConfigData(BaseModel): 22 | """ 23 | Container for configuration data. 24 | 25 | This model is used to wrap configuration data in a dictionary format. 26 | 27 | Attributes: 28 | data (dict): A dictionary containing configuration key-value pairs. 29 | """ 30 | 31 | data: dict 32 | -------------------------------------------------------------------------------- /server/schemas/dashboard.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, HttpUrl 2 | from typing import Optional, List 3 | 4 | 5 | class Task(BaseModel): 6 | """ 7 | Represents a task item. 8 | 9 | Attributes: 10 | id (int): The unique identifier for the task. 11 | task (str): The description or content of the task. 12 | completed (bool): The completion status of the task. 13 | """ 14 | 15 | id: int 16 | task: str 17 | completed: bool 18 | 19 | 20 | class RssFeed(BaseModel): 21 | """ 22 | Represents an RSS feed. 23 | 24 | Attributes: 25 | id (Optional[int]): The unique identifier for the RSS feed. Defaults to None. 26 | url (HttpUrl): The URL of the RSS feed. 27 | title (Optional[str]): The title of the RSS feed. Defaults to None. 28 | """ 29 | 30 | id: Optional[int] = None 31 | url: HttpUrl 32 | title: Optional[str] = None 33 | 34 | 35 | class RssFeedList(BaseModel): 36 | feeds: List[RssFeed] 37 | 38 | 39 | class RssItem(BaseModel): 40 | """ 41 | Represents an item within an RSS feed. 42 | 43 | Attributes: 44 | title (str): The title of the RSS item. 45 | link (HttpUrl): The URL link to the full content of the RSS item. 46 | description (Optional[str]): A brief description or summary of the RSS item. Defaults to None. 47 | published (Optional[str]): The publication date of the RSS item. Defaults to None. 48 | """ 49 | 50 | title: str 51 | link: HttpUrl 52 | description: str 53 | published: str 54 | feed_title: Optional[str] = None 55 | added_at: Optional[str] = None 56 | 57 | 58 | class RssFeedRefreshRequest(BaseModel): 59 | feed_id: Optional[int] = None 60 | 61 | 62 | class TodoItem(BaseModel): 63 | """ 64 | Represents a single to-do item. 65 | 66 | Attributes: 67 | id (Optional[int]): Unique identifier for the to-do item. 68 | task (str): Description of the task. 69 | completed (bool): Indicates whether the task is completed. 70 | """ 71 | 72 | id: Optional[int] = None 73 | task: str 74 | completed: bool = False 75 | -------------------------------------------------------------------------------- /server/schemas/grammars.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | from typing import List 3 | 4 | # RAG Chat Items: 5 | class ClinicalSuggestion(BaseModel): 6 | question: str 7 | 8 | class ClinicalSuggestionList(BaseModel): 9 | suggestions: List[ClinicalSuggestion] 10 | 11 | # RAG Collection Management 12 | class DiseaseNameResponse(BaseModel): 13 | """ 14 | Structured model for disease name identification. 15 | """ 16 | disease_name: str 17 | 18 | class FocusAreaResponse(BaseModel): 19 | """ 20 | Structured model for document focus area. 21 | """ 22 | focus_area: str 23 | 24 | class DocumentSourceResponse(BaseModel): 25 | """ 26 | Structured model for document source identification. 27 | """ 28 | source: str 29 | 30 | # Transcription Processing 31 | class FieldResponse(BaseModel): 32 | """ 33 | Structured model where each individual discussion point 34 | is in its own entry in the list. 35 | """ 36 | key_points: List[str] = Field( 37 | description="Individual discussion points extracted from the transcript" 38 | ) 39 | 40 | class RefinedResponse(BaseModel): 41 | """ 42 | Structured model where each individual discussion point 43 | is in its own entry in the list. 44 | """ 45 | key_points: List[str] 46 | 47 | class NarrativeResponse(BaseModel): 48 | """ 49 | Structured model where the content is returned as a narrative paragraph. 50 | """ 51 | narrative: str = Field( 52 | description="A narrative paragraph summarizing the content in a cohesive, flowing text" 53 | ) 54 | 55 | # Patient Analysis 56 | class PatientAnalysis(BaseModel): 57 | """ 58 | Structured model for generating a patient analysis digest. 59 | """ 60 | analysis: str = Field( 61 | description="A concise 3-4 sentence narrative digest of the most pressing patient tasks that need attention" 62 | ) 63 | 64 | class PreviousVisitSummary(BaseModel): 65 | """ 66 | Structured model for generating a summary of a patient's previous visit. 67 | """ 68 | summary: str = Field( 69 | description="A 2-3 sentence summary of the patient's previous visit, focusing on key clinical findings and outstanding tasks" 70 | ) 71 | 72 | # Reasoning 73 | class ClinicalReasoning(BaseModel): 74 | thinking: str 75 | summary: str 76 | differentials: List[str] 77 | investigations: List[str] 78 | clinical_considerations: List[str] 79 | 80 | # Letter 81 | class LetterDraft(BaseModel): 82 | """ 83 | Structured model for letter generation results. 84 | """ 85 | content: str = Field( 86 | description="The complete formatted letter content ready for display" 87 | ) 88 | 89 | # Adaptive Refinement 90 | class RefinementInstructions(BaseModel): 91 | """ 92 | Structured model for adaptive refinement instructions generation. 93 | """ 94 | instructions: List[str] = Field( 95 | description="List of 3-10 concise, actionable refinement instructions based on observed improvements", 96 | min_items=3, 97 | max_items=10 98 | ) 99 | 100 | class RefinedContent(BaseModel): 101 | """ 102 | Structured model for content refinement results. 103 | """ 104 | content: str = Field( 105 | description="The refined version of the original content with improvements applied" 106 | ) 107 | 108 | # RSS News Digests 109 | class ItemDigest(BaseModel): 110 | """ 111 | Structured model for individual RSS item digest. 112 | """ 113 | digest: str = Field( 114 | description="A 1-2 sentence summary highlighting the key finding or clinical implication of the article" 115 | ) 116 | 117 | class NewsDigest(BaseModel): 118 | """ 119 | Structured model for combined news digest. 120 | """ 121 | digest: str = Field( 122 | description="A concise 3-4 sentence digest summarizing multiple medical news articles with focus on clinical implications" 123 | ) 124 | -------------------------------------------------------------------------------- /server/schemas/letter.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | from typing import Optional, List, Dict 3 | from datetime import datetime 4 | 5 | class LetterTemplate(BaseModel): 6 | """ 7 | Represents a letter template. 8 | 9 | Attributes: 10 | id (Optional[int]): Template ID 11 | name (str): Template name 12 | instructions (str): Instructions for letter generation 13 | created_at (Optional[datetime]): Creation timestamp 14 | """ 15 | id: Optional[int] = None 16 | name: str 17 | instructions: str 18 | created_at: Optional[datetime] = None 19 | 20 | class Config: 21 | from_attributes = True 22 | 23 | class LetterRequest(BaseModel): 24 | """ 25 | Represents a request to generate a letter. 26 | 27 | Attributes: 28 | patientName (str): Name of the patient 29 | gender (str): Patient's gender 30 | template_data (dict): Template data 31 | additional_instruction (Optional[str]): Additional instructions for letter generation 32 | """ 33 | patientName: str 34 | gender: str 35 | template_data: dict 36 | additional_instruction: str | None = None 37 | context: Optional[List[Dict[str, str]]] = None 38 | 39 | class LetterSave(BaseModel): 40 | """ 41 | Represents a request to save a generated letter. 42 | 43 | Attributes: 44 | patientId (int): Unique identifier of the patient 45 | letter (str): Content of the letter to be saved 46 | """ 47 | patientId: int 48 | letter: str 49 | -------------------------------------------------------------------------------- /server/schemas/patient.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | from typing import Optional, Dict, Any, List 3 | 4 | class Patient(BaseModel): 5 | """ 6 | Represents a patient's medical record with template support. 7 | """ 8 | id: Optional[int] = None 9 | name: str 10 | dob: str 11 | ur_number: str 12 | gender: str 13 | encounter_date: str 14 | template_key: str 15 | template_data: Optional[Dict[str, Any]] = None 16 | raw_transcription: Optional[str] = None 17 | transcription_duration: Optional[float] = None 18 | process_duration: Optional[float] = None 19 | primary_condition: Optional[str] = None 20 | final_letter: Optional[str] = None 21 | encounter_summary: Optional[str] = None 22 | 23 | class Config: 24 | arbitrary_types_allowed = True 25 | 26 | class AdaptiveRefinementData(BaseModel): 27 | """ 28 | Represents adaptive refinement data for a specific field. 29 | """ 30 | initial_content: str 31 | modified_content: str 32 | 33 | class SavePatientRequest(BaseModel): 34 | """ 35 | Represents a request to save patient data. 36 | 37 | Attributes: 38 | patientData (Patient): Patient data to be saved 39 | """ 40 | patientData: Patient 41 | adaptive_refinement: Optional[Dict[str, AdaptiveRefinementData]] = None 42 | 43 | class Config: 44 | arbitrary_types_allowed = True 45 | 46 | 47 | 48 | class TranscribeResponse(BaseModel): 49 | """ 50 | Represents the response from a transcription process. 51 | 52 | Attributes: 53 | fields (Dict[str, Any]): Processed template fields 54 | rawTranscription (str): Raw transcription text 55 | transcriptionDuration (float): Time taken for transcription 56 | processDuration (float): Time taken for processing 57 | """ 58 | fields: Dict[str, Any] 59 | rawTranscription: str 60 | transcriptionDuration: float 61 | processDuration: float 62 | 63 | class Config: 64 | arbitrary_types_allowed = True 65 | 66 | class Job(BaseModel): 67 | """ 68 | Represents a single job/task for a patient. 69 | 70 | Attributes: 71 | id (int): Unique identifier for the job 72 | job (str): Description of the job 73 | completed (bool): Completion status of the job 74 | """ 75 | id: int 76 | job: str 77 | completed: bool 78 | 79 | class JobsListUpdate(BaseModel): 80 | """ 81 | Represents an update to a patient's jobs list. 82 | 83 | Attributes: 84 | patientId (int): Unique identifier of the patient 85 | jobsList (List[Job]): List of jobs for the patient 86 | """ 87 | patientId: int 88 | jobsList: List[Job] 89 | 90 | class DocumentProcessResponse(BaseModel): 91 | """ 92 | Represents the response from document processing. 93 | 94 | Attributes: 95 | primaryHistory (str): Processed primary history 96 | additionalHistory (str): Processed additional history 97 | investigations (str): Processed investigations 98 | processDuration (float): Time taken for processing 99 | """ 100 | primaryHistory: str 101 | additionalHistory: str 102 | investigations: str 103 | processDuration: float 104 | 105 | class Condition(BaseModel): 106 | """ 107 | Represents a medical condition. 108 | 109 | Attributes: 110 | condition_name (Optional[str]): Name of the condition 111 | """ 112 | condition_name: Optional[str] = None 113 | 114 | class TemplateData(BaseModel): 115 | """ 116 | Represents template data for a patient encounter. 117 | """ 118 | field_key: str 119 | content: Any 120 | -------------------------------------------------------------------------------- /server/schemas/rag.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | class CommitRequest(BaseModel): 4 | """ 5 | Represents a request to commit a new document to a collection. 6 | 7 | Attributes: 8 | disease_name (str): The name of the disease associated with the document. 9 | focus_area (str): The specific focus area or topic of the document. 10 | document_source (str): The source or origin of the document. 11 | filename (str): The name of the file to be committed. 12 | """ 13 | disease_name: str 14 | focus_area: str 15 | document_source: str 16 | filename: str 17 | 18 | class ModifyCollectionRequest(BaseModel): 19 | """ 20 | Represents a request to modify the name of a collection. 21 | 22 | Attributes: 23 | old_name (str): The current name of the collection to be modified. 24 | new_name (str): The new name to assign to the collection. 25 | """ 26 | old_name: str 27 | new_name: str 28 | 29 | class DeleteFileRequest(BaseModel): 30 | """ 31 | Represents a request to delete a specific file from a collection. 32 | 33 | Attributes: 34 | collection_name (str): The name of the collection containing the file. 35 | file_name (str): The name of the file to be deleted. 36 | """ 37 | collection_name: str 38 | file_name: str 39 | -------------------------------------------------------------------------------- /server/schemas/templates.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field, validator 2 | from typing import List, Optional, Union, Literal, Dict, Any 3 | from enum import Enum 4 | 5 | VALID_FIELD_TYPES = Literal["text", "number", "date", "boolean", "list", "structured"] 6 | 7 | class FormatStyle(str, Enum): 8 | BULLETS = "bullets" 9 | NUMBERED = "numbered" 10 | NARRATIVE = "narrative" 11 | HEADING_WITH_BULLETS = "heading_with_bullets" 12 | LAB_VALUES = "lab_values" 13 | 14 | class TemplateField(BaseModel): 15 | field_key: str 16 | field_name: str 17 | field_type: str 18 | required: bool = False 19 | persistent: bool = False 20 | system_prompt: str 21 | initial_prompt: Optional[str] = None 22 | format_schema: Optional[dict] = None 23 | style_example: str 24 | refinement_rules: Optional[List[str]] = None 25 | adaptive_refinement_instructions: Optional[List[str]] = None 26 | 27 | @validator('field_type') 28 | def validate_field_type(cls, v): 29 | valid_types = ["text", "number", "date", "boolean", "list", "structured"] 30 | if v not in valid_types: 31 | raise ValueError(f"field_type must be one of {valid_types}") 32 | return v 33 | 34 | class AdaptiveRefinementRequest(BaseModel): 35 | initial_content: str 36 | modified_content: str 37 | 38 | class ClinicalTemplate(BaseModel): 39 | template_key: str 40 | template_name: str 41 | fields: List[TemplateField] 42 | deleted: bool = False 43 | created_at: Optional[str] = None 44 | updated_at: Optional[str] = None 45 | 46 | class Config: 47 | extra = "allow" 48 | 49 | class TemplateResponse(BaseModel): 50 | field_key: str 51 | content: Union[str, int, bool, List[str], Dict[str, Any]] 52 | 53 | class ProcessedTemplate(BaseModel): 54 | template_key: str 55 | fields: Dict[str, Union[str, int, bool, List[str], Dict[str, Any]]] 56 | process_duration: float 57 | 58 | class TemplateFieldSchema(BaseModel): 59 | field_key: str 60 | field_name: str 61 | field_type: str = "text" 62 | required: bool = False 63 | description: str 64 | example_value: Optional[str] = None 65 | 66 | class TemplateSectionSchema(BaseModel): 67 | field_name: str 68 | format_style: FormatStyle 69 | bullet_type: Optional[str] = None 70 | section_starter: str 71 | example_text: str 72 | persistent: bool = False 73 | required: bool = False 74 | 75 | class ExtractedTemplate(BaseModel): 76 | sections: List[TemplateSectionSchema] 77 | suggested_name: str 78 | note_type: str 79 | -------------------------------------------------------------------------------- /server/server.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from pathlib import Path 4 | import uvicorn 5 | from fastapi import FastAPI, HTTPException 6 | from fastapi.middleware.cors import CORSMiddleware 7 | from fastapi.responses import FileResponse 8 | from fastapi.staticfiles import StaticFiles 9 | from server.database.config import ConfigManager 10 | from apscheduler.schedulers.asyncio import AsyncIOScheduler 11 | from server.database.analysis import generate_daily_analysis, run_nightly_reasoning 12 | 13 | logging.basicConfig( 14 | level=logging.INFO, 15 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' 16 | ) 17 | logger = logging.getLogger(__name__) 18 | 19 | app = FastAPI( 20 | title="Phlox", 21 | ) 22 | 23 | scheduler = AsyncIOScheduler() 24 | 25 | 26 | # CORS configuration 27 | app.add_middleware( 28 | CORSMiddleware, 29 | allow_origins=["*"], 30 | allow_credentials=True, 31 | allow_methods=["*"], 32 | allow_headers=["*"], 33 | ) 34 | 35 | # Constants 36 | DATA_DIR = Path("/usr/src/app/data") 37 | BUILD_DIR = Path("/usr/src/app/build") 38 | IS_TESTING = os.getenv("TESTING", "false").lower() == "true" 39 | 40 | 41 | # Initialize config_manager and run migrations 42 | logger.info("Initializing database and running migrations...") 43 | from server.database.config import config_manager 44 | 45 | # Then load API submodules 46 | from server.api import chat, config, dashboard, patient, rag, transcribe, templates, letter 47 | 48 | # Schedule daily analysis 49 | scheduler.add_job(generate_daily_analysis, "cron", hour=3) 50 | 51 | # Schedule reasoning analysis 52 | scheduler.add_job(run_nightly_reasoning, "cron", hour=4) 53 | 54 | # Start the scheduler when the app starts 55 | @app.on_event("startup") 56 | async def startup_event(): 57 | scheduler.start() 58 | # Run analysis if none exists or if last one is old 59 | await generate_daily_analysis() 60 | 61 | 62 | @app.get("/test-db") 63 | async def test_db(): 64 | try: 65 | result = test_database() 66 | logger.info(f"Database test succeeded: {result}") 67 | return {"success": "Database test succeeded", "result": result} 68 | except Exception as e: 69 | logger.error(f"Database test failed: {str(e)}") 70 | raise HTTPException( 71 | status_code=500, detail=f"Database test failed: {str(e)}" 72 | ) 73 | 74 | 75 | # Include routers 76 | app.include_router(patient.router, prefix="/api/patient") 77 | app.include_router(transcribe.router, prefix="/api/transcribe") 78 | app.include_router(dashboard.router, prefix="/api/dashboard") 79 | app.include_router(rag.router, prefix="/api/rag") 80 | app.include_router(config.router, prefix="/api/config") 81 | app.include_router(chat.router, prefix="/api/chat") 82 | app.include_router(templates.router, prefix="/api/templates") 83 | app.include_router(letter.router, prefix="/api/letter") 84 | 85 | # React app routes 86 | @app.get("/new-patient") 87 | @app.get("/settings") 88 | @app.get("/rag") 89 | @app.get("/clinic-summary") 90 | @app.get("/outstanding-tasks") 91 | async def serve_react_app(): 92 | return FileResponse(BUILD_DIR / "index.html") 93 | 94 | 95 | # Serve static files 96 | app.mount("/", StaticFiles(directory=BUILD_DIR, html=True), name="static") 97 | 98 | 99 | # Catch-all route for any other paths 100 | @app.get("/{full_path:path}") 101 | async def catch_all(full_path: str): 102 | return FileResponse(BUILD_DIR / "index.html") 103 | 104 | 105 | if __name__ == "__main__": 106 | config = uvicorn.Config( 107 | "server.server:app", 108 | host="0.0.0.0", 109 | port=int(os.getenv("PORT", 5000)), 110 | timeout_keep_alive=300, 111 | timeout_graceful_shutdown=10, 112 | loop="asyncio", 113 | workers=1, 114 | http="httptools", 115 | loop_wait=0.0, 116 | ws_ping_interval=None, 117 | ws_ping_timeout=None, 118 | buffer_size=0 119 | ) 120 | server = uvicorn.Server(config) 121 | server.run() 122 | -------------------------------------------------------------------------------- /server/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloodworks-io/phlox/7bc351cb51aa0c7ccabd6547cdbe3a66e1ec8da6/server/tests/__init__.py -------------------------------------------------------------------------------- /server/tests/test_chat.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the chat endpoint. 3 | Uses TestClient for a synchronous test and mocks out external dependencies. 4 | """ 5 | 6 | import json 7 | from unittest.mock import patch, MagicMock 8 | import pytest 9 | from fastapi.testclient import TestClient 10 | from fastapi import FastAPI 11 | from server.api.chat import router 12 | 13 | app = FastAPI() 14 | # Note: The chat router returns a StreamingResponse. 15 | # For testing we simulate reading the full streamed content. 16 | app.include_router(router, prefix="/api/chat") 17 | client = TestClient(app) 18 | 19 | 20 | def test_chat_endpoint_streaming(): 21 | with patch("server.api.chat.ChatEngine", autospec=True) as MockChatEngine: 22 | mock_engine_instance = MockChatEngine.return_value 23 | async def fake_generate(): 24 | yield "data: " + json.dumps({"chunk": "Part 1"}) + "\n\n" 25 | yield "data: " + json.dumps({"chunk": "Part 2"}) + "\n\n" 26 | mock_engine_instance.stream_chat.return_value = fake_generate() 27 | 28 | test_payload = { 29 | "messages": [ 30 | {"role": "user", "content": "What is the capital of France?"} 31 | ] 32 | } 33 | response = client.post("/api/chat", json=test_payload) 34 | 35 | # Fix: Use response.content instead of response.iter_lines() 36 | streamed_output = response.content.decode("utf-8") 37 | 38 | assert "Part 1" in streamed_output 39 | assert "Part 2" in streamed_output 40 | assert response.status_code == 200 41 | assert response.headers["content-type"].startswith("text/event-stream") 42 | 43 | 44 | if __name__ == "__main__": 45 | test_chat_endpoint_streaming() 46 | -------------------------------------------------------------------------------- /server/tests/test_config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for configuration endpoints. 3 | Uses TestClient and checks JSON response structure. 4 | """ 5 | 6 | import pytest 7 | from fastapi.testclient import TestClient 8 | from fastapi import FastAPI 9 | from server.api.config import router 10 | 11 | app = FastAPI() 12 | app.include_router(router, prefix="/api/config") 13 | client = TestClient(app) 14 | 15 | 16 | def is_valid_json(response): 17 | try: 18 | response.json() 19 | return True 20 | except ValueError: 21 | return False 22 | 23 | 24 | def test_get_prompts(): 25 | response = client.get("/api/config/prompts") 26 | assert response.status_code == 200 27 | assert is_valid_json(response) 28 | data = response.json() 29 | # Expect prompts to be a dict 30 | assert isinstance(data, dict) 31 | 32 | 33 | def test_get_config(): 34 | response = client.get("/api/config/global") 35 | assert response.status_code == 200 36 | assert is_valid_json(response) 37 | data = response.json() 38 | # Expect config to be a dict 39 | assert isinstance(data, dict) 40 | 41 | 42 | def test_get_all_options(): 43 | response = client.get("/api/config/ollama") 44 | assert response.status_code == 200 45 | data = response.json() 46 | assert isinstance(data, dict) 47 | 48 | 49 | def test_update_prompts(): 50 | new_prompts = { 51 | "TEST_PROMPT": { 52 | "system": "Test System Prompt", 53 | } 54 | } 55 | response = client.post("/api/config/prompts", json=new_prompts) 56 | assert response.status_code == 200 57 | data = response.json() 58 | assert "message" in data or "updated" in data.get("message", "").lower() 59 | 60 | 61 | def test_update_config(): 62 | new_config = {"TEST_CONFIG": "test_value"} 63 | response = client.post("/api/config/global", json=new_config) 64 | assert response.status_code == 200 65 | data = response.json() 66 | message = data.get("message", "") 67 | assert "message" in data and ("success" in message.lower()) 68 | 69 | def test_update_options(): 70 | new_options = {"TEST_OPTION": "test_option_value"} 71 | response = client.post("/api/config/ollama/TEST_CATEGORY", json=new_options) 72 | assert response.status_code == 200 73 | data = response.json() 74 | assert "updated" in data.get("message", "").lower() 75 | 76 | 77 | def test_reset_to_defaults(): 78 | response = client.post("/api/config/reset-to-defaults") 79 | assert response.status_code == 200 80 | data = response.json() 81 | assert "reset" in data.get("message", "").lower() 82 | -------------------------------------------------------------------------------- /server/tests/test_letter.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for letter endpoints. 3 | We patch generation and saving functions. 4 | """ 5 | import json 6 | import pytest 7 | from fastapi.testclient import TestClient 8 | from fastapi import FastAPI 9 | from server.api.letter import router as letter_router 10 | 11 | app = FastAPI() 12 | app.include_router(letter_router, prefix="/api/letter") 13 | client = TestClient(app) 14 | 15 | 16 | def test_generate_letter(monkeypatch): 17 | # Patch generate_letter_content to return a dummy letter 18 | def fake_generate_letter_content(*args, **kwargs): 19 | return "This is a generated letter." 20 | monkeypatch.setattr("server.api.letter.generate_letter_content", fake_generate_letter_content) 21 | payload = { 22 | "patientName": "Smith, John", 23 | "gender": "M", 24 | "template_data": {"key": "value"}, 25 | "additional_instruction": "Please include urgent follow-up.", 26 | "context": [] 27 | } 28 | response = client.post("/api/letter/generate", json=payload) 29 | assert response.status_code == 200 30 | data = response.json() 31 | assert "letter" in data 32 | assert "generated letter" in data["letter"] 33 | 34 | 35 | @pytest.mark.asyncio # Add this decorator 36 | async def test_generate_letter(monkeypatch): 37 | # Make fake_generate_letter_content async 38 | async def fake_generate_letter_content(*args, **kwargs): 39 | return "This is a generated letter." 40 | monkeypatch.setattr("server.api.letter.generate_letter_content", fake_generate_letter_content) 41 | payload = { 42 | "patientId": 123, 43 | "letter": "This is a saved letter." 44 | } 45 | response = client.post("/api/letter/save", json=payload) 46 | assert response.status_code == 200 47 | data = response.json() 48 | assert "message" in data 49 | assert "saved" in data["message"].lower() 50 | 51 | 52 | def test_fetch_letter(monkeypatch): 53 | async def fake_fetch_patient_letter(patientId): 54 | return "Fetched letter content." 55 | monkeypatch.setattr("server.api.letter.fetch_patient_letter", fake_fetch_patient_letter) 56 | response = client.get("/api/letter/fetch-letter?patientId=123") 57 | assert response.status_code == 200 58 | data = response.json() 59 | assert "letter" in data 60 | assert "Fetched letter content" in data["letter"] 61 | 62 | 63 | def test_get_templates(monkeypatch): 64 | # Patch get_letter_templates to return a dummy list 65 | def fake_get_letter_templates(): 66 | return [{"id": 1, "name": "Test Template", "instructions": "Do this"}] 67 | monkeypatch.setattr("server.api.letter.get_letter_templates", fake_get_letter_templates) 68 | response = client.get("/api/letter/templates") 69 | assert response.status_code == 200 70 | data = response.json() 71 | assert "templates" in data 72 | assert isinstance(data["templates"], list) 73 | assert "default_template_id" in data 74 | 75 | 76 | def test_create_template(monkeypatch): 77 | # Patch save_letter_template 78 | def fake_save_letter_template(template): 79 | return 42 80 | monkeypatch.setattr("server.api.letter.save_letter_template", fake_save_letter_template) 81 | payload = {"id": None, "name": "New Template", "instructions": "Test instructions"} 82 | response = client.post("/api/letter/templates", json=payload) 83 | assert response.status_code == 200 84 | data = response.json() 85 | assert data.get("id") == 42 86 | 87 | 88 | def test_update_template(monkeypatch): 89 | # Patch update_letter_template to return success 90 | monkeypatch.setattr("server.api.letter.update_letter_template", lambda id, template: True) 91 | payload = {"id": None, "name": "Updated Template", "instructions": "Updated instructions"} 92 | response = client.put("/api/letter/templates/1", json=payload) 93 | assert response.status_code == 200 94 | data = response.json() 95 | assert "updated" in data.get("message", "").lower() 96 | 97 | 98 | def test_delete_template(monkeypatch): 99 | # Patch delete_letter_template to simulate successful deletion 100 | monkeypatch.setattr("server.api.letter.delete_letter_template", lambda id: True) 101 | response = client.delete("/api/letter/templates/1") 102 | assert response.status_code == 200 103 | data = response.json() 104 | assert "deleted" in data.get("message", "").lower() 105 | -------------------------------------------------------------------------------- /server/tests/test_patient.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for patient endpoints. 3 | Assumes your patient-related endpoints are included from server/api/patient.py. 4 | """ 5 | 6 | import json 7 | import pytest 8 | from fastapi.testclient import TestClient 9 | from fastapi import FastAPI 10 | from server.api.patient import router as patient_router 11 | 12 | # Create a minimal FastAPI app with the patient router. 13 | app = FastAPI() 14 | app.include_router(patient_router, prefix="/api/patient") 15 | client = TestClient(app) 16 | 17 | def test_get_patients(): 18 | # Assumes that GET /api/patients?date=2023-06-15 returns a list (possibly empty) 19 | response = client.get("/api/patient/list?date=2023-06-15") 20 | assert response.status_code == 200 21 | data = response.json() 22 | # Data should be a list 23 | assert isinstance(data, list) 24 | 25 | @pytest.mark.asyncio 26 | async def test_get_patient_not_found(monkeypatch): 27 | """Test GET /api/patient/{id} with non-existent ID""" 28 | def fake_get_patient_by_id(*args): 29 | from fastapi import HTTPException 30 | raise HTTPException(status_code=404, detail="Patient not found") 31 | 32 | # Also need to import HTTPException in server/api/patient.py 33 | monkeypatch.setattr("server.database.patient.get_patient_by_id", fake_get_patient_by_id) 34 | response = client.get("/api/patient/id/999999") 35 | assert response.status_code == 404 36 | 37 | def test_search_patient(): 38 | # Query search-patient endpoint with a dummy UR number 39 | response = client.get("/api/patient/search?ur_number=NON_EXISTENT") 40 | assert response.status_code == 200 41 | data = response.json() 42 | # Expect data to be a list 43 | assert isinstance(data, list) 44 | 45 | @pytest.fixture 46 | def mock_summarize(monkeypatch): 47 | async def fake_summarize(*args, **kwargs): 48 | return "Test summary", "Test condition" 49 | monkeypatch.setattr("server.utils.helpers.summarize_encounter", fake_summarize) 50 | return fake_summarize 51 | 52 | # For save and update endpoints, we patch the database functions. 53 | @pytest.mark.asyncio 54 | async def test_save_patient(monkeypatch): 55 | # Mock summarize_encounter to avoid actual LLM calls 56 | async def mock_summarize_encounter(*args, **kwargs): 57 | return "Test summary", "Test condition" 58 | monkeypatch.setattr("server.api.patient.summarize_encounter", mock_summarize_encounter) 59 | 60 | payload = { 61 | "patientData": { 62 | "name": "Doe, Jane", 63 | "dob": "1980-01-01", 64 | "ur_number": "URTEST001", 65 | "gender": "F", 66 | "encounter_date": "2023-06-15", 67 | "template_key": "test", 68 | "template_data": {}, 69 | "raw_transcription": "", 70 | "transcription_duration": 0, 71 | "process_duration": 0, 72 | "primary_condition": "", 73 | "final_letter": "", 74 | "encounter_summary": "" 75 | } 76 | } 77 | 78 | def fake_save_patient(*args): 79 | return 123 80 | monkeypatch.setattr("server.api.patient.save_patient", fake_save_patient) 81 | 82 | response = client.post("/api/patient/save", json=payload) 83 | assert response.status_code == 200 84 | 85 | def test_delete_patient(monkeypatch): 86 | # Patch delete_patient_by_id to simulate a successful deletion 87 | from server.database.patient import delete_patient_by_id 88 | 89 | def fake_delete_patient_by_id(pid: int): 90 | return True 91 | 92 | monkeypatch.setattr("server.api.patient.delete_patient_by_id", fake_delete_patient_by_id) 93 | response = client.delete("/api/patient/id/123") 94 | assert response.status_code == 200 95 | data = response.json() 96 | assert "message" in data 97 | assert "deleted" in data["message"].lower() 98 | -------------------------------------------------------------------------------- /server/tests/test_rag.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for RAG endpoints. 3 | We patch ChromaManager methods to simulate vector database interactions. 4 | """ 5 | import json 6 | import pytest 7 | from fastapi.testclient import TestClient 8 | from fastapi import FastAPI 9 | from server.api.rag import router as rag_router 10 | 11 | app = FastAPI() 12 | app.include_router(rag_router, prefix="/api/rag") 13 | client = TestClient(app) 14 | 15 | def test_get_files(monkeypatch): 16 | # Patch chroma_manager.list_collections to return dummy collections 17 | dummy_collections = ["disease_a", "disease_b"] 18 | monkeypatch.setattr("server.api.rag.chroma_manager.list_collections", lambda: dummy_collections) 19 | response = client.get("/api/rag/files") 20 | assert response.status_code == 200 21 | data = response.json() 22 | assert "files" in data 23 | assert set(data["files"]) == set(dummy_collections) 24 | 25 | def test_get_collection_files(monkeypatch): 26 | # Patch chroma_manager.get_files_for_collection 27 | monkeypatch.setattr("server.api.rag.chroma_manager.get_files_for_collection", lambda name: ["file1", "file2"]) 28 | response = client.get("/api/rag/collection_files/test_collection") 29 | assert response.status_code == 200 30 | data = response.json() 31 | assert "files" in data 32 | assert isinstance(data["files"], list) 33 | 34 | def test_modify_collection(monkeypatch): 35 | # Patch chroma_manager.modify_collection_name to return True 36 | monkeypatch.setattr("server.api.rag.chroma_manager.modify_collection_name", lambda old, new: True) 37 | payload = {"old_name": "old_collection", "new_name": "new_collection"} 38 | response = client.post("/api/rag/modify", json=payload) 39 | assert response.status_code == 200 40 | data = response.json() 41 | assert "renamed successfully" in data.get("message", "").lower() 42 | 43 | def test_delete_collection(monkeypatch): 44 | # Patch chroma_manager.delete_collection 45 | monkeypatch.setattr("server.api.rag.chroma_manager.delete_collection", lambda name: True) 46 | response = client.delete("/api/rag/delete-collection/test_collection") 47 | assert response.status_code == 200 48 | data = response.json() 49 | assert "deleted successfully" in data.get("message", "").lower() 50 | 51 | 52 | def test_commit_to_vectordb(monkeypatch): 53 | # We simply patch the commit_to_vectordb method to not raise. 54 | monkeypatch.setattr("server.api.rag.chroma_manager.commit_to_vectordb", lambda disease, focus, source, fname: None) 55 | payload = { 56 | "disease_name": "disease_a", 57 | "focus_area": "diagnosis", 58 | "document_source": "journal", 59 | "filename": "doc.pdf" 60 | } 61 | response = client.post("/api/rag/commit-to-vectordb", json=payload) 62 | assert response.status_code == 200 63 | data = response.json() 64 | assert "committed" in data.get("message", "").lower() 65 | 66 | def test_get_rag_suggestions(monkeypatch): 67 | # Patch generate_specialty_suggestions to return a dummy list 68 | monkeypatch.setattr("server.api.rag.generate_specialty_suggestions", lambda: ["Suggestion 1", "Suggestion 2"]) 69 | response = client.get("/api/rag/suggestions") 70 | assert response.status_code == 200 71 | data = response.json() 72 | assert "suggestions" in data 73 | assert isinstance(data["suggestions"], list) 74 | assert "Suggestion 1" in data["suggestions"] 75 | 76 | def test_clear_database(monkeypatch): 77 | # Patch clear_database in chroma_manager to simulate success. 78 | monkeypatch.setattr("server.api.rag.chroma_manager.delete_collection", lambda name: True) 79 | response = client.post("/api/rag/clear-database") 80 | assert response.status_code == 200 81 | data = response.json() 82 | assert "cleared successfully" in data.get("message", "").lower() 83 | -------------------------------------------------------------------------------- /server/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloodworks-io/phlox/7bc351cb51aa0c7ccabd6547cdbe3a66e1ec8da6/server/utils/__init__.py -------------------------------------------------------------------------------- /src/components/common/Buttons.js: -------------------------------------------------------------------------------- 1 | // Reusable button components with predefined styles for different actions. 2 | import { Button, IconButton } from "@chakra-ui/react"; 3 | import { RepeatIcon } from "@chakra-ui/icons"; 4 | 5 | // Primary Action Buttons 6 | export const GreenButton = ({ children, ...props }) => ( 7 | 10 | ); 11 | 12 | export const RedButton = ({ children, ...props }) => ( 13 | 16 | ); 17 | 18 | export const OrangeButton = ({ children, ...props }) => ( 19 | 22 | ); 23 | 24 | export const TertiaryButton = ({ children, ...props }) => ( 25 | 28 | ); 29 | 30 | export const GreyButton = ({ children, ...props }) => ( 31 | 34 | ); 35 | 36 | // Utility Buttons 37 | export const SettingsButton = ({ children, ...props }) => ( 38 | 41 | ); 42 | 43 | export const SettingsIconButton = ({ ...props }) => ( 44 | 45 | ); 46 | 47 | export const SummaryButton = ({ children, ...props }) => ( 48 | 51 | ); 52 | 53 | // Navigation Buttons 54 | export const NavButton = ({ children, ...props }) => ( 55 | 58 | ); 59 | 60 | export const SmallNavButton = ({ children, ...props }) => ( 61 | 64 | ); 65 | 66 | // Toggle Buttons 67 | export const CollapseToggle = ({ ...props }) => ( 68 | 69 | ); 70 | 71 | export const DarkToggle = ({ ...props }) => ( 72 | 73 | ); 74 | 75 | // Search Button 76 | export const SearchButton = ({ ...props }) => ( 77 | 78 | ); 79 | 80 | // Mode Switch Button 81 | export const ModeSwitchButton = ({ children, isActive, ...props }) => ( 82 | 88 | ); 89 | export const RefreshIconButton = ({ isLoading, onClick, ...props }) => ( 90 | } 92 | onClick={onClick} 93 | isLoading={isLoading} 94 | aria-label="Refresh" 95 | size="sm" 96 | className="settings-button" 97 | borderRadius="sm" 98 | {...props} 99 | /> 100 | ); 101 | -------------------------------------------------------------------------------- /src/components/common/Toast.js: -------------------------------------------------------------------------------- 1 | // Custom toast component for displaying notifications with different statuses. 2 | import { 3 | Alert, 4 | AlertTitle, 5 | AlertDescription, 6 | Box, 7 | CloseButton, 8 | Flex, 9 | } from "@chakra-ui/react"; 10 | import { 11 | CheckCircleIcon, 12 | InfoIcon, 13 | WarningIcon, 14 | WarningTwoIcon, 15 | } from "@chakra-ui/icons"; 16 | 17 | export function CustomToast(props) { 18 | const { 19 | status, 20 | variant = "solid", 21 | id, 22 | title, 23 | description, 24 | isClosable, 25 | onClose, 26 | colorScheme, 27 | } = props; 28 | 29 | const getStatusIcon = (status) => { 30 | switch (status) { 31 | case "success": 32 | return ; 33 | case "error": 34 | return ; 35 | case "warning": 36 | return ; 37 | case "info": 38 | return ; 39 | default: 40 | return null; 41 | } 42 | }; 43 | 44 | const ids = id 45 | ? { 46 | root: `toast-${id}`, 47 | title: `toast-${id}-title`, 48 | description: `toast-${id}-description`, 49 | } 50 | : undefined; 51 | 52 | return ( 53 | 63 | 64 | {getStatusIcon(status)} 65 | 66 | {title && {title}} 67 | {description && ( 68 | 69 | {description} 70 | 71 | )} 72 | 73 | 74 | {isClosable && ( 75 | 83 | )} 84 | 85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /src/components/landing/DailyAnalysisPanel.js: -------------------------------------------------------------------------------- 1 | // Component displaying daily analysis digest, including incomplete jobs. 2 | import React from "react"; 3 | import { 4 | Box, 5 | Flex, 6 | Icon, 7 | Text, 8 | VStack, 9 | HStack, 10 | IconButton, 11 | } from "@chakra-ui/react"; 12 | import { RepeatIcon } from "@chakra-ui/icons"; 13 | import { FaChartLine, FaFileAlt, FaHistory } from "react-icons/fa"; 14 | 15 | const DailyAnalysisPanel = ({ 16 | analysis, 17 | incompleteJobs, 18 | isAnalysisRefreshing, 19 | refreshAnalysis, 20 | }) => { 21 | return ( 22 | 23 | 24 | 25 | 26 | Daily Intelligence Digest 27 | 28 | 29 | {incompleteJobs} pending 30 | 31 | 32 | 33 | } 35 | onClick={refreshAnalysis} 36 | aria-label="Refresh analysis" 37 | size="sm" 38 | className="settings-button" 39 | borderRadius="sm" 40 | isLoading={isAnalysisRefreshing} 41 | /> 42 | 43 | 44 | 52 | {analysis ? ( 53 | 54 | {analysis.analysis} 55 | 56 | 57 | 58 | Generated:{" "} 59 | {new Date( 60 | analysis.generated_at, 61 | ).toLocaleString()} 62 | 63 | 64 | 65 | ) : ( 66 | 72 | 73 | No analysis available 74 | 75 | )} 76 | 77 | 78 | ); 79 | }; 80 | 81 | export default DailyAnalysisPanel; 82 | -------------------------------------------------------------------------------- /src/components/modals/ConfirmLeaveModal.js: -------------------------------------------------------------------------------- 1 | // Modal component to confirm navigation away from the current page with unsaved changes. 2 | import { 3 | Modal, 4 | ModalOverlay, 5 | ModalContent, 6 | ModalHeader, 7 | ModalFooter, 8 | ModalBody, 9 | Button, 10 | } from "@chakra-ui/react"; 11 | 12 | const ConfirmLeaveModal = ({ isOpen, onClose, confirmNavigation }) => ( 13 | 14 | 15 | 16 | Confirm Navigation 17 | 18 | Are you sure you want to leave this page? Unsaved changes will 19 | be lost. 20 | 21 | 22 | 29 | 32 | 33 | 34 | 35 | ); 36 | 37 | export default ConfirmLeaveModal; 38 | -------------------------------------------------------------------------------- /src/components/patient/Chat.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef } from "react"; 2 | import ChatPanel from "./chat/ChatPanel"; 3 | 4 | const Chat = ({ 5 | isOpen, 6 | onClose, 7 | chatLoading, 8 | messages, 9 | setMessages, 10 | userInput, 11 | setUserInput, 12 | handleChat, 13 | showSuggestions, 14 | setShowSuggestions, 15 | rawTranscription, 16 | currentTemplate, 17 | patientData, 18 | }) => { 19 | const [dimensions, setDimensions] = useState({ width: 600, height: 400 }); 20 | const resizerRef = useRef(null); 21 | 22 | const handleMouseDown = (e) => { 23 | e.preventDefault(); 24 | window.addEventListener("mousemove", handleMouseMove); 25 | window.addEventListener("mouseup", handleMouseUp); 26 | }; 27 | 28 | const handleMouseMove = (e) => { 29 | setDimensions((prev) => ({ 30 | width: Math.max( 31 | 400, 32 | prev.width - 33 | (e.clientX - resizerRef.current.getBoundingClientRect().left) 34 | ), 35 | height: Math.max( 36 | 300, 37 | prev.height - 38 | (e.clientY - resizerRef.current.getBoundingClientRect().top) 39 | ), 40 | })); 41 | }; 42 | 43 | const handleMouseUp = () => { 44 | window.removeEventListener("mousemove", handleMouseMove); 45 | window.removeEventListener("mouseup", handleMouseUp); 46 | }; 47 | 48 | if (!isOpen) { 49 | return null; 50 | } 51 | 52 | return ( 53 | 70 | ); 71 | }; 72 | 73 | export default Chat; 74 | -------------------------------------------------------------------------------- /src/components/patient/ScribeTabs/PreviousVisitTab.js: -------------------------------------------------------------------------------- 1 | import { Box, Text, VStack } from "@chakra-ui/react"; 2 | 3 | const PreviousVisitTab = ({ previousVisitSummary }) => { 4 | return ( 5 | 6 | 7 | {previousVisitSummary ? ( 8 | 9 | {previousVisitSummary} 10 | 11 | ) : ( 12 | No previous visit records found. 13 | )} 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default PreviousVisitTab; 20 | -------------------------------------------------------------------------------- /src/components/patient/chat/ChatHeader.js: -------------------------------------------------------------------------------- 1 | import { Flex, Text, IconButton } from "@chakra-ui/react"; 2 | import { ChatIcon, CloseIcon } from "@chakra-ui/icons"; 3 | 4 | const ChatHeader = ({ title = "Chat With Phlox", onClose }) => { 5 | return ( 6 | 14 | 15 | 16 | {title} 17 | 18 | 19 | ); 20 | }; 21 | 22 | export default ChatHeader; 23 | -------------------------------------------------------------------------------- /src/components/patient/chat/ChatInput.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Flex, Input, IconButton } from "@chakra-ui/react"; 3 | import { ArrowUpIcon } from "@chakra-ui/icons"; 4 | 5 | const ChatInput = ({ 6 | userInput, 7 | setUserInput, 8 | handleSendMessage, 9 | chatLoading, 10 | }) => { 11 | return ( 12 | 13 | setUserInput(e.target.value)} 17 | onKeyPress={(e) => 18 | e.key === "Enter" && 19 | !e.shiftKey && 20 | (e.preventDefault(), handleSendMessage(userInput)) 21 | } 22 | mr="2" 23 | size="sm" 24 | className="chat-input" 25 | /> 26 | } 28 | onClick={() => handleSendMessage(userInput)} 29 | isDisabled={!userInput.trim() || chatLoading} 30 | isLoading={chatLoading} 31 | size="sm" 32 | aria-label="Send Message" 33 | className="chat-send-button" 34 | /> 35 | 36 | ); 37 | }; 38 | 39 | export default ChatInput; 40 | -------------------------------------------------------------------------------- /src/components/patient/chat/ChatSuggestions.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Flex, Button } from "@chakra-ui/react"; 3 | import { QuestionIcon } from "@chakra-ui/icons"; 4 | 5 | const ChatSuggestions = ({ handleSendMessage, userSettings }) => { 6 | if (!userSettings) return null; 7 | 8 | return ( 9 | 16 | 17 | {[1, 2, 3].map((n) => { 18 | const title = userSettings[`quick_chat_${n}_title`]; 19 | const prompt = userSettings[`quick_chat_${n}_prompt`]; 20 | if (!title || !prompt) return null; 21 | return ( 22 | 33 | ); 34 | })} 35 | 36 | 37 | ); 38 | }; 39 | 40 | export default ChatSuggestions; 41 | -------------------------------------------------------------------------------- /src/components/patient/chat/QuickChatButtons.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Tooltip, Button, Box } from "@chakra-ui/react"; 3 | import { QuestionIcon } from "@chakra-ui/icons"; 4 | import { emergeFromButton, AnimatedHStack } from "../../../theme/animations"; 5 | 6 | const QuickChatButtons = ({ userSettings, handleSendMessage }) => { 7 | if (!userSettings) return null; 8 | 9 | return ( 10 | 11 | {[1, 2, 3].map((n) => { 12 | const title = userSettings[`quick_chat_${n}_title`]; 13 | const prompt = userSettings[`quick_chat_${n}_prompt`]; 14 | if (!title || !prompt) return null; 15 | const showTip = title.length > 25; 16 | return ( 17 | 25 | 45 | 46 | ); 47 | })} 48 | 49 | ); 50 | }; 51 | 52 | export default QuickChatButtons; 53 | -------------------------------------------------------------------------------- /src/components/patient/letter/CustomInstructionsInput.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Box, Text, Textarea } from "@chakra-ui/react"; 3 | 4 | const CustomInstructionsInput = ({ 5 | additionalInstructions, 6 | setAdditionalInstructions, 7 | }) => { 8 | return ( 9 | 10 | 11 | Custom Instructions: 12 | 13 |