├── .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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 | You need to enable JavaScript to run this app.
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 |
8 | {children}
9 |
10 | );
11 |
12 | export const RedButton = ({ children, ...props }) => (
13 |
14 | {children}
15 |
16 | );
17 |
18 | export const OrangeButton = ({ children, ...props }) => (
19 |
20 | {children}
21 |
22 | );
23 |
24 | export const TertiaryButton = ({ children, ...props }) => (
25 |
26 | {children}
27 |
28 | );
29 |
30 | export const GreyButton = ({ children, ...props }) => (
31 |
32 | {children}
33 |
34 | );
35 |
36 | // Utility Buttons
37 | export const SettingsButton = ({ children, ...props }) => (
38 |
39 | {children}
40 |
41 | );
42 |
43 | export const SettingsIconButton = ({ ...props }) => (
44 |
45 | );
46 |
47 | export const SummaryButton = ({ children, ...props }) => (
48 |
49 | {children}
50 |
51 | );
52 |
53 | // Navigation Buttons
54 | export const NavButton = ({ children, ...props }) => (
55 |
56 | {children}
57 |
58 | );
59 |
60 | export const SmallNavButton = ({ children, ...props }) => (
61 |
62 | {children}
63 |
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 |
86 | {children}
87 |
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 |
27 | Leave
28 |
29 |
30 | Cancel
31 |
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 | }
25 | m="1.5"
26 | size="md"
27 | variant="outline"
28 | onClick={() => handleSendMessage(prompt)}
29 | className="chat-suggestions"
30 | >
31 | {title}
32 |
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 | }
27 | size="sm"
28 | variant="outline"
29 | onClick={() => handleSendMessage(prompt)}
30 | className="quick-chat-buttons-collapsed"
31 | flex="1"
32 | minWidth="0"
33 | >
34 |
42 | {title}
43 |
44 |
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 |
28 | );
29 | };
30 |
31 | export default CustomInstructionsInput;
32 |
--------------------------------------------------------------------------------
/src/components/patient/letter/FloatingLetterButton.js:
--------------------------------------------------------------------------------
1 | import React, { forwardRef } from "react";
2 | import { Box, IconButton, Tooltip } from "@chakra-ui/react";
3 | import { FaEnvelope } from "react-icons/fa";
4 |
5 | const FloatingLetterButton = forwardRef(({ onClick, isActive }, ref) => {
6 | return (
7 |
15 |
21 | }
23 | colorScheme="teal"
24 | onClick={onClick}
25 | aria-label={isActive ? "Close Letter" : "Open Letter"}
26 | borderRadius="full"
27 | size="lg"
28 | bg={isActive ? "#6aafa7" : "#81c8be"} // Darker when active
29 | className={`letter-icon ${isActive ? "letter-icon-active" : ""}`}
30 | boxShadow={
31 | isActive ? "0 0 10px rgba(129, 200, 190, 0.6)" : "md"
32 | }
33 | width="3em"
34 | height="3em"
35 | fontSize="2xl"
36 | _hover={{
37 | bg: isActive ? "#5a9e97" : "#6aafa7",
38 | transform: "scale(1.05)",
39 | }}
40 | transition="all 0.2s ease-in-out"
41 | />
42 |
43 |
44 | );
45 | });
46 |
47 | export default FloatingLetterButton;
48 |
--------------------------------------------------------------------------------
/src/components/patient/letter/PanelFooterActions.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Flex, Button, Spinner } from "@chakra-ui/react";
3 | import { RepeatIcon, CopyIcon, CheckIcon } from "@chakra-ui/icons";
4 | import { FaSave } from "react-icons/fa";
5 |
6 | const PanelFooterActions = ({
7 | handleGenerateLetter,
8 | handleCopy,
9 | handleSave,
10 | recentlyCopied,
11 | saveState,
12 | letterLoading,
13 | additionalInstructions,
14 | }) => {
15 | const getSaveButtonProps = () => {
16 | switch (saveState) {
17 | case "saving":
18 | return {
19 | leftIcon: ,
20 | children: "Saving...",
21 | };
22 | case "saved":
23 | return {
24 | leftIcon: ,
25 | children: "Saved!",
26 | };
27 | default:
28 | return {
29 | leftIcon: ,
30 | children: "Save Letter",
31 | };
32 | }
33 | };
34 |
35 | return (
36 |
37 | handleGenerateLetter(additionalInstructions)}
39 | className="red-button"
40 | leftIcon={ }
41 | isDisabled={letterLoading || saveState !== "idle"}
42 | >
43 | Regenerate Letter
44 |
45 |
46 | : }
50 | mr="2"
51 | isDisabled={letterLoading}
52 | >
53 | {recentlyCopied ? "Copied!" : "Copy Letter"}
54 |
55 |
61 |
62 |
63 | );
64 | };
65 |
66 | export default PanelFooterActions;
67 |
--------------------------------------------------------------------------------
/src/components/patient/letter/RefinementPanel.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | Box,
4 | Flex,
5 | IconButton,
6 | Text,
7 | Textarea,
8 | Button,
9 | Spinner,
10 | } from "@chakra-ui/react";
11 | import { EditIcon, CloseIcon } from "@chakra-ui/icons";
12 |
13 | const RefinementPanel = ({
14 | refinementInput,
15 | setRefinementInput,
16 | handleRefinement,
17 | loading,
18 | setIsRefining,
19 | suggestions = [
20 | "More formal",
21 | "More concise",
22 | "Add detail",
23 | "Improve clarity",
24 | ],
25 | }) => (
26 |
37 | {loading && (
38 |
50 |
51 |
52 | )}
53 |
54 |
55 |
56 |
57 | Refine Letter
58 |
59 |
60 | }
62 | onClick={() => setIsRefining(false)}
63 | aria-label="Close refinement"
64 | variant="ghost"
65 | size="sm"
66 | className="collapse-toggle"
67 | />
68 |
69 |
70 |
71 |
72 | {suggestions.map((suggestion) => (
73 | setRefinementInput(suggestion)}
77 | className="chat-suggestions"
78 | >
79 | {suggestion}
80 |
81 | ))}
82 |
83 |
84 |
109 |
110 | );
111 |
112 | export default RefinementPanel;
113 |
--------------------------------------------------------------------------------
/src/components/patient/letter/TemplateSelector.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Box, Text, HStack, Button } from "@chakra-ui/react";
3 |
4 | const TemplateSelector = ({
5 | letterTemplates,
6 | selectedTemplate,
7 | onTemplateSelect,
8 | }) => {
9 | return (
10 |
11 |
12 | Letter Template:
13 |
14 |
15 | {letterTemplates.map((template) => (
16 | onTemplateSelect(template)}
26 | className="grey-button"
27 | minWidth="auto"
28 | flexShrink={0}
29 | >
30 | {template.name}
31 |
32 | ))}
33 | onTemplateSelect("custom")}
39 | className="grey-button"
40 | minWidth="auto"
41 | flexShrink={0}
42 | >
43 | Custom
44 |
45 |
46 |
47 | );
48 | };
49 |
50 | export default TemplateSelector;
51 |
--------------------------------------------------------------------------------
/src/components/rag/DeleteModal.js:
--------------------------------------------------------------------------------
1 | // Modal component to confirm delete operations for collections or files.
2 | import {
3 | Modal,
4 | ModalOverlay,
5 | ModalContent,
6 | ModalHeader,
7 | ModalCloseButton,
8 | ModalBody,
9 | ModalFooter,
10 | Button,
11 | Spinner,
12 | Text,
13 | } from "@chakra-ui/react";
14 |
15 | const DeleteModal = ({ isOpen, onClose, onDelete, item }) => {
16 | const isDeleting = isOpen && !item;
17 | if (!item) return null; // Don't render if no item to delete
18 |
19 | return (
20 |
21 |
22 |
23 |
24 | {item.type === "file" ? "Delete File" : "Delete Collection"}
25 |
26 |
27 |
28 | {isDeleting ? (
29 |
30 | ) : (
31 |
32 | Are you sure you want to delete the{" "}
33 | {item.type === "file" ? "file" : "collection"} "
34 | {item.name}"
35 | {item.type === "file" &&
36 | ` from the collection "${item.collection}"`}
37 | ?
38 |
39 | )}
40 |
41 |
42 |
48 | {isDeleting ? "Deleting..." : "Delete"}
49 |
50 |
55 | Cancel
56 |
57 |
58 |
59 |
60 | );
61 | };
62 |
63 | export default DeleteModal;
64 |
--------------------------------------------------------------------------------
/src/components/settings/SettingsActions.js:
--------------------------------------------------------------------------------
1 | // Action buttons for saving and restoring settings.
2 | import { HStack, Button } from "@chakra-ui/react";
3 |
4 | const SettingsActions = ({ onSave, onRestoreDefaults }) => {
5 | return (
6 |
7 |
8 | Save Changes
9 |
10 |
11 | Restore Defaults
12 |
13 |
14 | );
15 | };
16 |
17 | export default SettingsActions;
18 |
--------------------------------------------------------------------------------
/src/components/sidebar/DeleteConfirmationModal.js:
--------------------------------------------------------------------------------
1 | import {
2 | Modal,
3 | ModalOverlay,
4 | ModalContent,
5 | ModalHeader,
6 | ModalFooter,
7 | ModalBody,
8 | ModalCloseButton,
9 | Button,
10 | } from "@chakra-ui/react";
11 |
12 | const DeleteConfirmationModal = ({
13 | isOpen,
14 | onClose,
15 | onDelete,
16 | patientName,
17 | }) => {
18 | return (
19 |
20 |
21 |
22 | Delete Patient
23 |
24 |
25 | {patientName
26 | ? `Are you sure you want to delete ${patientName}?`
27 | : "Are you sure you want to delete this patient?"}
28 |
29 |
30 |
31 | Delete
32 |
33 |
34 | Cancel
35 |
36 |
37 |
38 |
39 | );
40 | };
41 |
42 | export default DeleteConfirmationModal;
43 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | /* Global styles for Phlox. */
2 |
3 | body {
4 | margin: 0;
5 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
6 | "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji",
7 | "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
8 | background-color: #f0f2f5;
9 | }
10 |
11 | code {
12 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
13 | monospace;
14 | }
15 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import { ChakraProvider, ColorModeScript } from "@chakra-ui/react";
4 | import { BrowserRouter as Router } from "react-router-dom";
5 | import App from "./App";
6 | import "./index.css";
7 | import theme from "./theme";
8 | import { CustomToast } from "./components/common/Toast";
9 |
10 | const root = ReactDOM.createRoot(document.getElementById("root"));
11 | root.render(
12 |
21 |
22 |
23 |
24 |
25 | ,
26 | );
27 |
28 | export default App;
29 |
--------------------------------------------------------------------------------
/src/pages/ClinicSummary.js:
--------------------------------------------------------------------------------
1 | // Page component that renders a summary of patients for a selected date.
2 | import { useEffect, useState } from "react";
3 | import PatientTable from "../components/patient/PatientTable";
4 | import { settingsService } from "../utils/settings/settingsUtils";
5 |
6 | const ClinicSummary = ({
7 | selectedDate,
8 | handleSelectPatient,
9 | refreshSidebar,
10 | }) => {
11 | const [patients, setPatients] = useState([]);
12 | const [reasoningEnabled, setReasoningEnabled] = useState(false);
13 |
14 | useEffect(() => {
15 | const fetchConfig = async () => {
16 | const config = await settingsService.fetchConfig();
17 | setReasoningEnabled(config.REASONING_ENABLED);
18 | };
19 | fetchConfig();
20 | }, []);
21 |
22 | const fetchPatients = async (date, detailed = true) => {
23 | try {
24 | const response = await fetch(
25 | `/api/patient/list?date=${date}&detailed=${detailed}`,
26 | );
27 | if (!response.ok) {
28 | throw new Error("Network response was not ok");
29 | }
30 | const data = await response.json();
31 | setPatients(
32 | data.map((patient) => ({
33 | ...patient,
34 | activeSection: "summary",
35 | jobs_list: JSON.parse(patient.jobs_list || "[]"),
36 | })),
37 | );
38 | } catch (error) {
39 | console.error("Error fetching patients:", error);
40 | }
41 | };
42 |
43 | useEffect(() => {
44 | fetchPatients(selectedDate);
45 | }, [selectedDate]);
46 |
47 | return (
48 |
56 | );
57 | };
58 |
59 | export default ClinicSummary;
60 |
--------------------------------------------------------------------------------
/src/pages/OutstandingJobs.js:
--------------------------------------------------------------------------------
1 | // Page component listing patients with outstanding jobs.
2 | import { useEffect, useState } from "react";
3 | import PatientTable from "../components/patient/PatientTable";
4 | import { settingsService } from "../utils/settings/settingsUtils";
5 |
6 | const OutstandingJobs = ({ handleSelectPatient, refreshSidebar }) => {
7 | const [patients, setPatients] = useState([]);
8 | const [reasoningEnabled, setReasoningEnabled] = useState(false);
9 |
10 | useEffect(() => {
11 | const fetchConfig = async () => {
12 | const config = await settingsService.fetchConfig();
13 | setReasoningEnabled(config.REASONING_ENABLED);
14 | };
15 | fetchConfig();
16 | }, []);
17 |
18 | const fetchPatientsWithJobs = async () => {
19 | try {
20 | const response = await fetch(`/api/patient/outstanding-jobs`);
21 | if (!response.ok) {
22 | throw new Error("Network response was not ok");
23 | }
24 | const data = await response.json();
25 | setPatients(
26 | data.map((patient) => ({
27 | ...patient,
28 | activeSection: "summary",
29 | jobs_list: JSON.parse(patient.jobs_list || "[]"),
30 | })),
31 | );
32 | } catch (error) {
33 | console.error("Error fetching patients with jobs:", error);
34 | }
35 | };
36 |
37 | useEffect(() => {
38 | fetchPatientsWithJobs();
39 | }, []);
40 |
41 | return (
42 |
51 | );
52 | };
53 |
54 | export default OutstandingJobs;
55 |
--------------------------------------------------------------------------------
/src/theme/animations.js:
--------------------------------------------------------------------------------
1 | import { keyframes } from "@emotion/react";
2 | import styled from "@emotion/styled";
3 | import { Box, HStack } from "@chakra-ui/react";
4 |
5 | export const emergeFromButton = keyframes`
6 | from {
7 | transform: scale(0.5) translateY(60px);
8 | opacity: 0;
9 | transform-origin: center right;
10 | }
11 | to {
12 | transform: scale(1) translateY(0);
13 | opacity: 1;
14 | transform-origin: center right;
15 | }
16 | `;
17 |
18 | export const loadingGradient = keyframes`
19 | 0% { background-position: 0% 50%; }
20 | 50% { background-position: 100% 50%; }
21 | 100% { background-position: 0% 50%; }
22 | `;
23 |
24 | export const slideUp = keyframes`
25 | from {
26 | transform: translateY(20px);
27 | opacity: 0;
28 | }
29 | to {
30 | transform: translateY(0);
31 | opacity: 1;
32 | }
33 | `;
34 |
35 | export const AnimatedChatPanel = styled(Box)`
36 | animation: ${emergeFromButton} 0.3s cubic-bezier(0.18, 0.89, 0.32, 1.28)
37 | forwards;
38 | transform-origin: bottom right;
39 | `;
40 |
41 | export const AnimatedHStack = styled(HStack)`
42 | animation: ${slideUp} 0.5s ease-out forwards;
43 | `;
44 |
45 | export const LoadingBox = styled(Box)`
46 | position: relative;
47 | overflow: hidden;
48 | &::after {
49 | content: "";
50 | position: absolute;
51 | top: 0;
52 | left: 0;
53 | right: 0;
54 | bottom: 0;
55 | background: linear-gradient(
56 | 90deg,
57 | rgba(255, 255, 255, 0) 0%,
58 | rgba(255, 255, 255, 0.4) 50%,
59 | rgba(255, 255, 255, 0) 100%
60 | );
61 | animation: ${loadingGradient} 1.5s ease-in-out infinite;
62 | background-size: 200% 100%;
63 | }
64 | `;
65 |
--------------------------------------------------------------------------------
/src/theme/colors.js:
--------------------------------------------------------------------------------
1 | // Defines the color palettes for the application in light and dark mode.
2 | import { lightenColor } from "./utils";
3 |
4 | const baseColorsLight = {
5 | base: "#eff1f5",
6 | secondary: "#e6e9ef",
7 | surface: "#ccd0da",
8 | surface2: "#acb0be",
9 | crust: "#dce0e8",
10 | overlay0: "#8c8fa1",
11 | textPrimary: "#4c4f69",
12 | textSecondary: "#6c6f85",
13 | textTertiary: "#5c5f77",
14 | textQuaternary: "#7c7f93",
15 | invertedText: "#e6e9ef",
16 | primaryButton: "#179299",
17 | dangerButton: "#d20f39",
18 | successButton: "#40a02b",
19 | secondaryButton: "#df8e1d",
20 | neutralButton: "#7287fd",
21 | tertiaryButton: "#dd7878",
22 | chatIcon: "#8839ef",
23 | extraButton: "#ea76cb",
24 | sidebar: {
25 | background: "#232634",
26 | item: "#414559",
27 | hover: "#626880",
28 | text: "#e6e9ef",
29 | },
30 | };
31 |
32 | const baseColorsDark = {
33 | base: "#24273a",
34 | secondary: "#1e2030",
35 | surface: "#363a4f",
36 | surface1: "#494d64",
37 | surface2: "#5b6078",
38 | crust: "#181926",
39 | overlay0: "#6e738d",
40 | overlay2: "#939ab7",
41 | textPrimary: "#cad3f5",
42 | textSecondary: "#a5adcb",
43 | textTertiary: "#b8c0e0",
44 | textQuaternary: "#6e738d",
45 | invertedText: "#cad3f5",
46 | primaryButton: "#8aadf4",
47 | dangerButton: "#ed8796",
48 | successButton: "#a6da95",
49 | secondaryButton: "#eed49f",
50 | neutralButton: "#b7bdf8",
51 | tertiaryButton: "#f5bde6",
52 | extraButton: "#f5c2e7",
53 | chatIcon: "#c6a0f6",
54 | sidebar: {
55 | background: "#1e2030",
56 | item: "#363a4f",
57 | hover: "#363a4f",
58 | },
59 | };
60 |
61 | export const colors = {
62 | light: {
63 | ...baseColorsLight,
64 | buttonHover: {
65 | primary: lightenColor(baseColorsLight.primaryButton),
66 | danger: lightenColor(baseColorsLight.dangerButton),
67 | success: lightenColor(baseColorsLight.successButton),
68 | secondary: lightenColor(baseColorsLight.secondaryButton),
69 | neutral: lightenColor(baseColorsLight.neutralButton),
70 | tertiary: lightenColor(baseColorsLight.tertiaryButton),
71 | },
72 | },
73 | dark: {
74 | ...baseColorsDark,
75 | buttonHover: {
76 | primary: lightenColor(baseColorsDark.primaryButton),
77 | danger: lightenColor(baseColorsDark.dangerButton),
78 | success: lightenColor(baseColorsDark.successButton),
79 | secondary: lightenColor(baseColorsDark.secondaryButton),
80 | neutral: lightenColor(baseColorsDark.neutralButton),
81 | tertiary: lightenColor(baseColorsDark.tertiaryButton),
82 | },
83 | },
84 | };
85 |
--------------------------------------------------------------------------------
/src/theme/config.js:
--------------------------------------------------------------------------------
1 | // Configuration settings for the application's theme.
2 | export const config = {
3 | initialColorMode: "system",
4 | useSystemColorMode: true,
5 | };
6 |
--------------------------------------------------------------------------------
/src/theme/index.js:
--------------------------------------------------------------------------------
1 | // Entry point for the Chakra UI theme, combining all theme configurations.
2 | import { extendTheme } from "@chakra-ui/react";
3 | import { config } from "./config";
4 | import { colors } from "./colors";
5 | import { typography } from "./typography";
6 | import { styles } from "./styles"; // Import from the index.js in the styles directory
7 | import { components } from "./components";
8 |
9 | const theme = extendTheme({ config, colors, typography, styles, components });
10 |
11 | export default theme;
12 |
--------------------------------------------------------------------------------
/src/theme/styles/base.js:
--------------------------------------------------------------------------------
1 | // Base styles including global styles for elements like body and headings
2 | import { colors } from "../colors";
3 | import { typography } from "../typography";
4 |
5 | export const baseStyles = (props) => ({
6 | body: {
7 | bg: props.colorMode === "light" ? colors.light.base : colors.dark.base,
8 | color:
9 | props.colorMode === "light"
10 | ? colors.light.textPrimary
11 | : colors.dark.textPrimary,
12 | },
13 | ".headings": {
14 | color:
15 | props.colorMode === "light"
16 | ? `${colors.light.textSecondary} !important`
17 | : `${colors.dark.textSecondary} !important`,
18 | fontWeight: "700",
19 | },
20 | h1: {
21 | ...typography.styles.h1,
22 | color:
23 | props.colorMode === "light"
24 | ? `${colors.light.textSecondary} !important`
25 | : `${colors.dark.textSecondary} !important`,
26 | },
27 | h2: {
28 | ...typography.styles.h2,
29 | color:
30 | props.colorMode === "light"
31 | ? `${colors.light.textSecondary} !important`
32 | : `${colors.dark.textSecondary} !important`,
33 | },
34 | h3: {
35 | ...typography.styles.h3,
36 | color:
37 | props.colorMode === "light"
38 | ? `${colors.light.textSecondary} !important`
39 | : `${colors.dark.textSecondary} !important`,
40 | },
41 | h4: {
42 | ...typography.styles.h4,
43 | color:
44 | props.colorMode === "light"
45 | ? colors.light.sidebar.text
46 | : colors.light.sidebar.text,
47 | },
48 | h5: {
49 | ...typography.styles.h5,
50 | color:
51 | props.colorMode === "light"
52 | ? colors.light.textSecondary
53 | : colors.dark.textSecondary,
54 | },
55 | h6: {
56 | ...typography.styles.h6,
57 | color:
58 | props.colorMode === "light"
59 | ? colors.light.textSecondary
60 | : colors.dark.textSecondary,
61 | },
62 | h7: {
63 | ...typography.styles.h7,
64 | color:
65 | props.colorMode === "light"
66 | ? colors.light.textSecondary
67 | : colors.dark.textSecondary,
68 | },
69 | p: {
70 | fontFamily: '"Roboto", sans-serif',
71 | fontSize: "1rem",
72 | lineHeight: "1.5",
73 | },
74 | });
75 |
--------------------------------------------------------------------------------
/src/theme/styles/checkbox.js:
--------------------------------------------------------------------------------
1 | // Styles for checkbox components
2 | import { colors } from "../colors";
3 |
4 | export const checkboxStyles = (props) => ({
5 | ".task-checkbox": {
6 | borderRadius: "sm !important",
7 | ".chakra-checkbox__control": {
8 | borderWidth: "1px !important",
9 | border:
10 | props.colorMode === "light"
11 | ? `1px solid ${colors.light.surface} !important`
12 | : `1px solid ${colors.dark.surface2} !important`,
13 | backgroundColor:
14 | props.colorMode === "light"
15 | ? `${colors.light.crust} !important`
16 | : `${colors.dark.crust} !important`,
17 | color:
18 | props.colorMode === "light"
19 | ? `${colors.light.textSecondary} !important`
20 | : `${colors.dark.textSecondary} !important`,
21 | },
22 | ".chakra-checkbox__label": {
23 | color:
24 | props.colorMode === "light"
25 | ? `${colors.light.textSecondary} !important`
26 | : `${colors.dark.textSecondary} !important`,
27 | },
28 | },
29 | });
30 |
--------------------------------------------------------------------------------
/src/theme/styles/documentExplorer.js:
--------------------------------------------------------------------------------
1 | // Styles specifically for the document explorer user interface.
2 | import { colors } from "../colors";
3 |
4 | export const documentExplorerStyles = (props) => ({
5 | ".documentExplorer-style": {
6 | backgroundColor:
7 | props.colorMode === "light"
8 | ? `${colors.light.base} !important`
9 | : `${colors.dark.crust} !important`,
10 | color:
11 | props.colorMode === "light"
12 | ? `${colors.light.textPrimary} !important`
13 | : `${colors.dark.textTertiary} !important`,
14 | border: "none !important",
15 | resize: "none !important",
16 | fontSize: "0.9rem !important",
17 | borderRadius: "4px !important",
18 | overflow: "hidden !important",
19 | whiteSpace: "pre-wrap !important",
20 | boxShadow: "none !important",
21 | },
22 | ".documentExplorer-button": {
23 | backgroundColor: "none !important",
24 | color:
25 | props.colorMode === "light"
26 | ? `${colors.light.textPrimary} !important`
27 | : `${colors.dark.textTertiary} !important`,
28 | },
29 | ".filelist-style": {
30 | backgroundColor:
31 | props.colorMode === "light"
32 | ? `${colors.light.base} !important`
33 | : `${colors.dark.crust} !important`,
34 | color:
35 | props.colorMode === "light"
36 | ? `${colors.light.textTertiary} !important`
37 | : `${colors.dark.textTertiary} !important`,
38 | },
39 | });
40 |
--------------------------------------------------------------------------------
/src/theme/styles/index.js:
--------------------------------------------------------------------------------
1 | // Combines all styles for the custom theme.
2 | import { baseStyles } from "./base";
3 | import { sidebarStyles } from "./sidebar";
4 | import { panelStyles } from "./panel";
5 | import { buttonStyles } from "./button";
6 | import { inputStyles } from "./input";
7 | import { modalStyles } from "./modal";
8 | import { floatingStyles } from "./floating";
9 | import { modeSelectorStyles } from "./modeSelector";
10 | import { toggleStyles } from "./toggle";
11 | import { documentExplorerStyles } from "./documentExplorer";
12 | import { checkboxStyles } from "./checkbox";
13 | import { tabStyles } from "./tab";
14 | import { scrollbarStyles } from "./scrollbar";
15 | import { colors } from "../colors";
16 | import { patientInfoStyles } from "./patientInfo";
17 |
18 | export const styles = {
19 | global: (props) => ({
20 | ...baseStyles(props),
21 | ...sidebarStyles(props),
22 | ...panelStyles(props),
23 | ...buttonStyles(props),
24 | ...inputStyles(props),
25 | ...modalStyles(props),
26 | ...floatingStyles(props),
27 | ...modeSelectorStyles(props),
28 | ...toggleStyles(props),
29 | ...documentExplorerStyles(props),
30 | ...checkboxStyles(props),
31 | ...tabStyles(props),
32 | ...scrollbarStyles(props),
33 | ...patientInfoStyles(props),
34 | ".main-bg": {
35 | // Keep miscellaneous styles here or in a dedicated file
36 | backgroundColor:
37 | props.colorMode === "light"
38 | ? colors.light.base
39 | : colors.dark.base,
40 | },
41 | ".flex-container": {
42 | display: "flex",
43 | justifyContent: "center",
44 | alignItems: "center",
45 | height: "50px",
46 | },
47 | ".landing-items": {
48 | backgroundColor:
49 | props.colorMode === "light"
50 | ? colors.light.base
51 | : colors.dark.crust,
52 | color:
53 | props.colorMode === "light"
54 | ? `${colors.light.textSecondary} !important`
55 | : `${colors.dark.textSecondary} !important`,
56 | border: "none",
57 | fontWeight: "normal",
58 | },
59 | ".green-icon": {
60 | color: `${colors.light.successButton} !important`,
61 | },
62 | ".red-icon": {
63 | color: `${colors.light.dangerButton} !important`,
64 | },
65 | ".yellow-icon": {
66 | color: `${colors.light.secondaryButton} !important`,
67 | },
68 | ".blue-icon": {
69 | color: `${colors.light.primaryButton} !important`,
70 | },
71 | }),
72 | };
73 |
--------------------------------------------------------------------------------
/src/theme/styles/modal.js:
--------------------------------------------------------------------------------
1 | // Styles for modal components.
2 | import { colors } from "../colors";
3 |
4 | export const modalStyles = (props) => ({
5 | ".modal-style": {
6 | backgroundColor:
7 | props.colorMode === "light"
8 | ? `${colors.light.secondary} !important`
9 | : `${colors.dark.secondary} !important`,
10 | border:
11 | props.colorMode === "light"
12 | ? `1px solid ${colors.light.surface} !important`
13 | : `1px solid ${colors.dark.surface} !important`,
14 | color:
15 | props.colorMode === "light"
16 | ? `${colors.light.textSecondary} !important`
17 | : `${colors.dark.textSecondary} !important`,
18 | fontSize: "0.9rem !important",
19 | padding: "0px !important",
20 | borderRadius: "sm !important",
21 | },
22 | ".collapse-toggle": {
23 | border: "none !important",
24 | borderRadius: "sm !important",
25 | color:
26 | props.colorMode === "light"
27 | ? `${colors.light.textTertiary} !important`
28 | : `${colors.dark.textTertiary} !important`,
29 | backgroundColor:
30 | props.colorMode === "light"
31 | ? `${colors.light.crust} !important`
32 | : `${colors.dark.base} !important`,
33 | },
34 |
35 | ".template-editor-modal": {
36 | backgroundColor:
37 | props.colorMode === "light"
38 | ? `${colors.light.base} !important`
39 | : `${colors.dark.crust} !important`,
40 | borderRadius: "sm !important",
41 | maxHeight: "70vh", // Adjust this value as needed
42 | maxWidth: "800px", // Adjust this value as needed
43 | overflowY: "auto",
44 | margin: "auto", // Center the modal
45 | },
46 |
47 | ".template-editor-header": {
48 | padding: "1rem",
49 | },
50 |
51 | ".template-editor-body": {
52 | backgroundColor:
53 | props.colorMode === "light" ? colors.light.base : colors.dark.crust,
54 | padding: "1.5rem",
55 | },
56 |
57 | ".template-editor-footer": {
58 | backgroundColor:
59 | props.colorMode === "light"
60 | ? `${colors.light.base} !important`
61 | : `${colors.dark.crust} !important`,
62 | padding: "1rem",
63 | },
64 | });
65 |
--------------------------------------------------------------------------------
/src/theme/styles/modeSelector.js:
--------------------------------------------------------------------------------
1 | // Styles for the mode selector components.
2 | import { colors } from "../colors";
3 |
4 | export const modeSelectorStyles = (props) => ({
5 | ".mode-selector": {
6 | position: "relative",
7 | backgroundColor:
8 | props.colorMode === "light"
9 | ? `${colors.light.crust} !important`
10 | : `${colors.dark.surface} !important`,
11 | border: props.colorMode === "light" ? "none !important" : `none`,
12 | borderRadius: "full !important",
13 | overflow: "hidden",
14 | width: "300px",
15 | height: "40px",
16 | ".chakra-button": {
17 | transition: "color 0.3s ease",
18 | height: "38px",
19 | "&:hover": {
20 | backgroundColor: "transparent !important",
21 | },
22 | },
23 | },
24 |
25 | // New compact template mode selector styles
26 | ".template-mode-selector": {
27 | position: "relative",
28 | backgroundColor:
29 | props.colorMode === "light"
30 | ? `${colors.light.crust} !important`
31 | : `${colors.dark.surface} !important`,
32 | border: props.colorMode === "light" ? "none !important" : `none`,
33 | borderRadius: "full !important",
34 | overflow: "hidden",
35 | width: "200px", // Smaller width
36 | height: "32px", // Smaller height
37 | ".chakra-button": {
38 | transition: "color 0.3s ease",
39 | height: "30px", // Smaller height
40 | fontSize: "sm", // Smaller font
41 | "&:hover": {
42 | backgroundColor: "transparent !important",
43 | },
44 | },
45 | },
46 |
47 | ".mode-selector-indicator": {
48 | position: "absolute",
49 | backgroundColor:
50 | props.colorMode === "light"
51 | ? `${colors.light.surface2} !important`
52 | : `${colors.dark.crust} !important`,
53 | borderRadius: "full !important",
54 | height: "calc(100% - 4px) !important",
55 | width: "50% !important",
56 | transition: "left 0.3s ease !important",
57 | },
58 |
59 | // Template mode selector indicator (same as above but with specific class)
60 | ".template-mode-selector-indicator": {
61 | position: "absolute",
62 | backgroundColor:
63 | props.colorMode === "light"
64 | ? `${colors.light.surface2} !important`
65 | : `${colors.dark.crust} !important`,
66 | borderRadius: "full !important",
67 | height: "calc(100% - 4px) !important",
68 | width: "50% !important",
69 | transition: "left 0.3s ease !important",
70 | },
71 |
72 | ".mode-selector-button": {
73 | flex: "1",
74 | variant: "ghost",
75 | backgroundColor: "transparent !important",
76 | "&:hover": {
77 | backgroundColor: "transparent !important",
78 | },
79 | color:
80 | props.colorMode === "light"
81 | ? `${colors.light.textSecondary} !important`
82 | : `${colors.dark.textSecondary} !important`,
83 | "&.active": {
84 | color:
85 | props.colorMode === "light"
86 | ? `${colors.light.base} !important`
87 | : `${colors.dark.invertedText} !important`,
88 | },
89 | },
90 |
91 | // Template mode selector button (same as above but with specific class)
92 | ".template-mode-selector-button": {
93 | flex: "1",
94 | variant: "ghost",
95 | backgroundColor: "transparent !important",
96 | "&:hover": {
97 | backgroundColor: "transparent !important",
98 | },
99 | color:
100 | props.colorMode === "light"
101 | ? `${colors.light.textSecondary} !important`
102 | : `${colors.dark.textSecondary} !important`,
103 | "&.active": {
104 | color:
105 | props.colorMode === "light"
106 | ? `${colors.light.base} !important`
107 | : `${colors.dark.invertedText} !important`,
108 | },
109 | },
110 | });
111 |
--------------------------------------------------------------------------------
/src/theme/styles/panel.js:
--------------------------------------------------------------------------------
1 | // Style definitions for panel components.
2 | import { colors } from "../colors";
3 |
4 | export const panelStyles = (props) => ({
5 | ".panel": {
6 | backgroundColor:
7 | props.colorMode === "light"
8 | ? colors.light.secondary
9 | : colors.dark.secondary,
10 | color:
11 | props.colorMode === "light"
12 | ? colors.light.textSecondary
13 | : colors.dark.textSecondary,
14 | borderRadius: "sm",
15 | shadow: "sm",
16 | },
17 | ".panel-header": {
18 | display: "flex",
19 | justifyContent: "space-between",
20 | alignItems: "center",
21 | borderBottom:
22 | props.colorMode === "light"
23 | ? `1px solid ${colors.light.surface} !important`
24 | : `1px solid ${colors.dark.surface} !important`,
25 | },
26 | ".panel-content": {
27 | backgroundColor:
28 | props.colorMode === "light" ? colors.light.base : colors.dark.crust,
29 | color:
30 | props.colorMode === "light"
31 | ? colors.light.textSecondary
32 | : colors.dark.textSecondary,
33 | borderRadius: "sm",
34 | padding: 4,
35 | maxHeight: "400px",
36 | overflowY: "auto",
37 | },
38 | ".panels-bg": {
39 | backgroundColor:
40 | props.colorMode === "light"
41 | ? colors.light.secondary
42 | : colors.dark.secondary,
43 | color:
44 | props.colorMode === "light"
45 | ? `${colors.light.textSecondary} !important`
46 | : `${colors.dark.textSecondary} !important`,
47 | borderColor: "#cecacd",
48 | border: "none !important",
49 | borderRadius: "md !important",
50 | fontSize: "1rem !important",
51 | fontWeight: "700",
52 | },
53 | ".summary-panels": {
54 | backgroundColor:
55 | props.colorMode === "light"
56 | ? colors.light.secondary
57 | : colors.dark.secondary,
58 | color:
59 | props.colorMode === "light"
60 | ? `${colors.light.textSecondary} !important`
61 | : `${colors.dark.textSecondary} !important`,
62 | borderColor: "#cecacd",
63 | border: "none !important",
64 | fontSize: "1rem !important",
65 | fontWeight: "normal",
66 | },
67 | ".summary-checkboxes": {
68 | backgroundColor:
69 | props.colorMode === "light"
70 | ? colors.light.secondary
71 | : colors.dark.secondary,
72 | color:
73 | props.colorMode === "light"
74 | ? `${colors.light.textSecondary} !important`
75 | : `${colors.dark.textSecondary} !important`,
76 | },
77 | });
78 |
--------------------------------------------------------------------------------
/src/theme/styles/patientInfo.js:
--------------------------------------------------------------------------------
1 | // Styles for the patient information bar component
2 | import { colors } from "../colors";
3 |
4 | export const patientInfoStyles = (props) => ({
5 | ".pill-box": {
6 | display: "inline-flex",
7 | justifyContent: "center",
8 | alignItems: "center",
9 | backgroundColor:
10 | props.colorMode === "light"
11 | ? `${colors.light.secondary} !important`
12 | : `${colors.dark.secondary} !important`,
13 | border: "none",
14 | padding: "10px 20px",
15 | borderRadius: "md",
16 | },
17 | ".pill-box-icons": {
18 | color:
19 | props.colorMode === "light"
20 | ? `${colors.light.textSecondary} !important`
21 | : `${colors.dark.textQuaternary} !important`,
22 | minWidth: "16px",
23 | marginRight: "8px",
24 | flexShrink: 0, // Prevent icon from shrinking
25 | },
26 |
27 | ".input-style": {
28 | backgroundColor:
29 | props.colorMode === "light"
30 | ? `${colors.light.base} !important`
31 | : `${colors.dark.crust} !important`,
32 | color:
33 | props.colorMode === "light"
34 | ? `${colors.light.textTertiary} !important`
35 | : `${colors.dark.textTertiary} !important`,
36 | border:
37 | props.colorMode === "light"
38 | ? `1px solid ${colors.light.surface} !important`
39 | : `${colors.dark.textTertiary} !important`,
40 | padding: "7px 8px !important",
41 | borderRadius: "md !important",
42 | fontSize: "0.9rem !important",
43 | },
44 | ".search-button": {
45 | borderLeft: "none !important",
46 | borderTopLeftRadius: "0 !important",
47 | borderBottomLeftRadius: "0 !important",
48 | borderTopRightRadius: "md !important",
49 | borderBottomRightRadius: "md !important",
50 | marginLeft: "-1px",
51 | backgroundColor:
52 | props.colorMode === "light"
53 | ? `${colors.light.surface2} !important`
54 | : `${colors.dark.base} !important`,
55 | border:
56 | props.colorMode === "light"
57 | ? `1px solid ${colors.light.surface2} !important`
58 | : `${colors.dark.textTertiary} !important`,
59 | color: "#575279 !important",
60 | height: "32px !important",
61 | minWidth: "32px !important",
62 | flexShrink: 0, // Prevent button from shrinking
63 | },
64 | ".search-button:hover": {
65 | backgroundColor:
66 | props.colorMode === "light"
67 | ? `${colors.light.surface} !important`
68 | : `${colors.dark.surface2} !important`,
69 | },
70 | });
71 |
--------------------------------------------------------------------------------
/src/theme/styles/scrollbar.js:
--------------------------------------------------------------------------------
1 | // Styles for the application's scrollbars
2 | import { colors } from "../colors";
3 |
4 | export const scrollbarStyles = (props) => ({
5 | ".custom-scrollbar": {
6 | scrollbarWidth: "thin",
7 | scrollbarColor: `${colors.light.surface2} transparent`,
8 | },
9 | ".custom-scrollbar::-webkit-scrollbar": {
10 | width: "6px",
11 | },
12 | ".custom-scrollbar::-webkit-scrollbar-track": {
13 | background: "transparent",
14 | },
15 | ".custom-scrollbar::-webkit-scrollbar-thumb": {
16 | backgroundColor:
17 | props.colorMode === "light"
18 | ? `${colors.light.surface2}B3`
19 | : `${colors.dark.surface2}B3`,
20 | borderRadius: "6px",
21 | border: `none`,
22 | },
23 | });
24 |
--------------------------------------------------------------------------------
/src/theme/styles/tab.js:
--------------------------------------------------------------------------------
1 | // Defines visual styles for tab components within the application.
2 | import { colors } from "../colors";
3 |
4 | export const tabStyles = (props) => ({
5 | ".tab-style": {
6 | backgroundColor: "transparent !important",
7 | borderRadius: "0 !important",
8 | borderTopLeftRadius: "md !important",
9 | borderTopRightRadius: "md !important",
10 | marginBottom: "-1px",
11 | "&[aria-selected=true]": {
12 | backgroundColor:
13 | props.colorMode === "light"
14 | ? `${colors.light.base} !important`
15 | : `${colors.dark.crust} !important`,
16 | color:
17 | props.colorMode === "light"
18 | ? `${colors.light.textSecondary} !important`
19 | : `${colors.dark.textSecondary} !important`,
20 | border: "none",
21 | },
22 | "&[aria-selected=false]": {
23 | backgroundColor: "transparent !important",
24 | color:
25 | props.colorMode === "light"
26 | ? `${colors.light.textTertiary} !important`
27 | : `${colors.dark.textTertiary} !important`,
28 | },
29 | "&:hover": {
30 | backgroundColor:
31 | props.colorMode === "light"
32 | ? `${colors.light.surface} !important`
33 | : `${colors.dark.surface} !important`,
34 | },
35 | },
36 | ".tab-panel-container": {
37 | minHeight: "180px !important",
38 | display: "flex !important",
39 | alignItems: "center !important",
40 | justifyContent: "center !important",
41 | },
42 | });
43 |
--------------------------------------------------------------------------------
/src/theme/styles/toggle.js:
--------------------------------------------------------------------------------
1 | // Defines visual styles for toggle buttons and switches.
2 | import { colors } from "../colors";
3 |
4 | export const toggleStyles = (props) => ({
5 | ".transcript-mode": {
6 | display: "inline-flex",
7 | justifyContent: "center",
8 | alignItems: "center",
9 | backgroundColor:
10 | props.colorMode === "light"
11 | ? `${colors.light.crust} !important`
12 | : `${colors.dark.crust} !important`,
13 | border:
14 | props.colorMode === "light"
15 | ? `1px solid ${colors.light.surface} !important`
16 | : "none !important",
17 | color: "#575279 !important",
18 | fontSize: "0.9rem !important",
19 | },
20 | ".switch-mode": {
21 | display: "inline-flex",
22 | borderRadius: "lg !important",
23 | justifyContent: "center",
24 | alignItems: "center",
25 | backgroundColor:
26 | props.colorMode === "light"
27 | ? `${colors.light.crust} !important`
28 | : `${colors.dark.crust} !important`,
29 | border:
30 | props.colorMode === "light"
31 | ? `1px solid ${colors.light.surface} !important`
32 | : `1px solid ${colors.dark.surface} !important`,
33 | color:
34 | props.colorMode === "light"
35 | ? `${colors.light.textSecondary} !important`
36 | : `${colors.dark.textSecondary} !important`,
37 | },
38 | ".dark-toggle": {
39 | backgroundColor:
40 | props.colorMode === "light"
41 | ? `${colors.light.secondary} !important`
42 | : `${colors.dark.secondary} !important`,
43 | border: "none !important",
44 | color:
45 | props.colorMode === "light"
46 | ? `${colors.light.textTertiary} !important`
47 | : `${colors.dark.textTertiary} !important`,
48 | borderRadius: "md !important",
49 | },
50 | ".dark-toggle:hover": {
51 | backgroundColor:
52 | props.colorMode === "light"
53 | ? `${colors.light.surface} !important`
54 | : `${colors.dark.surface} !important`,
55 | cursor: "pointer",
56 | },
57 | });
58 |
--------------------------------------------------------------------------------
/src/theme/typography.js:
--------------------------------------------------------------------------------
1 | // Defines the typographic styles for the application
2 | export const typography = {
3 | fonts: {
4 | heading: '"Space Grotesk", sans-serif',
5 | body: '"Roboto", sans-serif',
6 | },
7 | styles: {
8 | h1: {
9 | fontFamily: '"Space Grotesk", sans-serif',
10 | fontSize: ["1.75rem", "2rem"],
11 | fontWeight: "700",
12 | lineHeight: "1.2",
13 | marginBottom: "1rem",
14 | },
15 | h2: {
16 | fontFamily: '"Space Grotesk", sans-serif',
17 | fontSize: ["1.5rem", "1.75rem"],
18 | fontWeight: "700",
19 | lineHeight: "1.2",
20 | marginBottom: "0.5rem",
21 | },
22 | h3: {
23 | fontFamily: '"Space Grotesk", sans-serif',
24 | fontSize: ["1rem", "1.275rem"],
25 | fontWeight: "700",
26 | lineHeight: "1.2",
27 | },
28 | h4: {
29 | fontFamily: '"Space Grotesk", sans-serif',
30 | fontSize: ["0.875rem", "1rem"],
31 | fontWeight: "600",
32 | lineHeight: "1",
33 | },
34 | h5: {
35 | fontFamily: '"Space Grotesk", sans-serif',
36 | fontSize: ["1.125rem", "1.25rem"],
37 | fontWeight: "600",
38 | lineHeight: "1.25",
39 | marginBottom: "0.5rem",
40 | },
41 | h6: {
42 | fontFamily: '"Space Grotesk", sans-serif',
43 | fontSize: ["1rem", "1.125rem"],
44 | fontWeight: "600",
45 | lineHeight: "1.25",
46 | marginBottom: "0.5rem",
47 | },
48 | h7: {
49 | fontFamily: '"Roboto", sans-serif',
50 | fontWeight: "700 !important",
51 | lineHeight: "1.5",
52 | },
53 | },
54 | };
55 |
--------------------------------------------------------------------------------
/src/theme/utils.js:
--------------------------------------------------------------------------------
1 | // Utility functions for handling colors
2 | export function darkenColor(hexColor, amount = 0.1) {
3 | if (hexColor.startsWith("whiteAlpha")) {
4 | return `whiteAlpha.700`;
5 | }
6 | const hex = hexColor.replace("#", "");
7 | const bigint = parseInt(hex, 16);
8 | let r = (bigint >> 16) & 255;
9 | let g = (bigint >> 8) & 255;
10 | let b = bigint & 255;
11 | r = Math.max(0, Math.min(255, Math.round(r * (1 - amount))));
12 | g = Math.max(0, Math.min(255, Math.round(g * (1 - amount))));
13 | b = Math.max(0, Math.min(255, Math.round(b * (1 - amount))));
14 | return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0")}`;
15 | }
16 |
17 | export function lightenColor(hexColor, amount = 0.1) {
18 | const hex = hexColor.replace("#", "");
19 | const bigint = parseInt(hex, 16);
20 | let r = (bigint >> 16) & 255;
21 | let g = (bigint >> 8) & 255;
22 | let b = bigint & 255;
23 | r = Math.max(0, Math.min(255, Math.round(r + (255 - r) * amount)));
24 | g = Math.max(0, Math.min(255, Math.round(g + (255 - g) * amount)));
25 | b = Math.max(0, Math.min(255, Math.round(b + (255 - b) * amount)));
26 | return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0")}`;
27 | }
28 |
--------------------------------------------------------------------------------
/src/utils/api/chatApi.js:
--------------------------------------------------------------------------------
1 | // API functions for interacting with the chat service backend.
2 | import { handleApiRequest } from "../helpers/apiHelpers";
3 |
4 | export const chatApi = {
5 | sendMessage: async (messages, rawTranscription = null) => {
6 | return handleApiRequest({
7 | apiCall: () =>
8 | fetch(`/api/chat`, {
9 | method: "POST",
10 | headers: { "Content-Type": "application/json" },
11 | body: JSON.stringify({
12 | messages,
13 | raw_transcription: rawTranscription,
14 | }),
15 | }),
16 | errorMessage: "Error in chat communication",
17 | });
18 | },
19 |
20 | generateLetter: async (letterData) => {
21 | return handleApiRequest({
22 | apiCall: () =>
23 | fetch("/api/generate-letter", {
24 | method: "POST",
25 | headers: { "Content-Type": "application/json" },
26 | body: JSON.stringify(letterData),
27 | }),
28 | successMessage: "Letter generated successfully.",
29 | errorMessage: "Error generating letter",
30 | });
31 | },
32 |
33 | streamMessage: async function* (messages, rawTranscription = null) {
34 | const response = await fetch(`/api/chat`, {
35 | method: "POST",
36 | headers: { "Content-Type": "application/json" },
37 | body: JSON.stringify({
38 | messages,
39 | raw_transcription: rawTranscription,
40 | }),
41 | });
42 |
43 | if (!response.ok) {
44 | throw new Error(`HTTP error! status: ${response.status}`);
45 | }
46 |
47 | const reader = response.body.getReader();
48 | const decoder = new TextDecoder();
49 |
50 | while (true) {
51 | const { value, done } = await reader.read();
52 | if (done) break;
53 |
54 | const chunk = decoder.decode(value);
55 | const lines = chunk.split("\n\n");
56 |
57 | for (const line of lines) {
58 | if (line.trim() && line.startsWith("data: ")) {
59 | try {
60 | const data = JSON.parse(line.slice(6));
61 | if (data.type === "end" && data.function_response) {
62 | // Handle function response at the end of stream
63 | yield {
64 | type: "context",
65 | content: data.function_response.reduce(
66 | (acc, item, index) => {
67 | acc[index + 1] = item;
68 | return acc;
69 | },
70 | {},
71 | ),
72 | };
73 | } else {
74 | yield data;
75 | }
76 | await new Promise((resolve) => setTimeout(resolve, 0));
77 | } catch (error) {
78 | console.error("Error parsing chunk:", error);
79 | }
80 | }
81 | }
82 | }
83 | },
84 | };
85 |
--------------------------------------------------------------------------------
/src/utils/api/letterApi.js:
--------------------------------------------------------------------------------
1 | import { handleApiRequest } from "../helpers/apiHelpers";
2 |
3 | export const letterApi = {
4 | fetchLetterTemplates: () =>
5 | handleApiRequest({
6 | apiCall: () => fetch("/api/letter/templates"),
7 | errorMessage: "Failed to fetch letter templates",
8 | }),
9 |
10 | getLetterTemplate: (templateId) =>
11 | handleApiRequest({
12 | apiCall: () => fetch(`/api/letter/templates/${templateId}`),
13 | errorMessage: "Failed to fetch letter template",
14 | }),
15 |
16 | createLetterTemplate: (template) =>
17 | handleApiRequest({
18 | apiCall: () =>
19 | fetch("/api/letter/templates", {
20 | method: "POST",
21 | headers: { "Content-Type": "application/json" },
22 | body: JSON.stringify(template),
23 | }),
24 | successMessage: "Letter template created successfully",
25 | errorMessage: "Failed to create letter template",
26 | }),
27 |
28 | updateLetterTemplate: (templateId, template) =>
29 | handleApiRequest({
30 | apiCall: () =>
31 | fetch(`/api/letter/templates/${templateId}`, {
32 | method: "PUT",
33 | headers: { "Content-Type": "application/json" },
34 | body: JSON.stringify(template),
35 | }),
36 | successMessage: "Letter template updated successfully",
37 | errorMessage: "Failed to update letter template",
38 | }),
39 |
40 | deleteLetterTemplate: (templateId) =>
41 | handleApiRequest({
42 | apiCall: () =>
43 | fetch(`/api/letter/templates/${templateId}`, {
44 | method: "DELETE",
45 | }),
46 | successMessage: "Letter template deleted successfully",
47 | errorMessage: "Failed to delete letter template",
48 | }),
49 |
50 | resetLetterTemplates: () =>
51 | handleApiRequest({
52 | apiCall: () =>
53 | fetch("/api/letter/letter/templates/reset", {
54 | method: "POST",
55 | }),
56 | successMessage: "Letter templates reset to defaults",
57 | errorMessage: "Failed to reset letter templates",
58 | }),
59 |
60 | generateLetter: async ({
61 | patientName,
62 | gender,
63 | template_data,
64 | context,
65 | additional_instruction,
66 | }) => {
67 | console.log("Letter Generation Request:", {
68 | patientName,
69 | gender,
70 | template_data,
71 | context,
72 | additional_instruction,
73 | });
74 |
75 | return handleApiRequest({
76 | apiCall: () =>
77 | fetch("/api/letter/generate", {
78 | method: "POST",
79 | headers: { "Content-Type": "application/json" },
80 | body: JSON.stringify({
81 | patientName,
82 | gender,
83 | template_data,
84 | additional_instruction,
85 | context,
86 | }),
87 | }),
88 | errorMessage: "Failed to generate letter",
89 | });
90 | },
91 |
92 | fetchLetter: async (patientId) => {
93 | return handleApiRequest({
94 | apiCall: () =>
95 | fetch(`/api/letter/fetch-letter?patientId=${patientId}`),
96 | });
97 | },
98 |
99 | saveLetter: (patientId, content) =>
100 | handleApiRequest({
101 | apiCall: () =>
102 | fetch("/api/letter/save", {
103 | method: "POST",
104 | headers: { "Content-Type": "application/json" },
105 | body: JSON.stringify({ patientId, letter: content }),
106 | }),
107 | }),
108 | };
109 |
--------------------------------------------------------------------------------
/src/utils/api/patientApi.js:
--------------------------------------------------------------------------------
1 | // API functions for patient related data operations.
2 | import { handleApiRequest } from "../helpers/apiHelpers";
3 |
4 | export const patientApi = {
5 | async savePatientData(saveRequest, toast, refreshSidebar) {
6 | try {
7 | const response = await fetch("/api/patient/save", {
8 | method: "POST",
9 | headers: {
10 | "Content-Type": "application/json",
11 | },
12 | body: JSON.stringify(saveRequest),
13 | });
14 |
15 | if (!response.ok) {
16 | const errorData = await response.json();
17 | throw new Error(errorData.detail || "Failed to save patient");
18 | }
19 |
20 | const data = await response.json();
21 |
22 | toast({
23 | title: "Success",
24 | description: "Patient data saved successfully",
25 | status: "success",
26 | duration: 3000,
27 | isClosable: true,
28 | });
29 |
30 | if (refreshSidebar) {
31 | await refreshSidebar();
32 | }
33 |
34 | return data;
35 | } catch (error) {
36 | console.error("Error saving patient:", error);
37 | throw error;
38 | }
39 | },
40 |
41 | searchPatient: async (urNumber, callbacks = {}) => {
42 | return handleApiRequest({
43 | apiCall: () => fetch(`/api/patient/search?ur_number=${urNumber}`),
44 | onSuccess: (data) => {
45 | if (data.length > 0) {
46 | const latestEncounter = data[0];
47 |
48 | // Safely iterate over callbacks
49 | if (callbacks && typeof callbacks === "object") {
50 | Object.entries(callbacks).forEach(([key, setter]) => {
51 | if (
52 | typeof setter === "function" &&
53 | latestEncounter[key] !== undefined
54 | ) {
55 | setter(latestEncounter[key]);
56 | }
57 | });
58 | }
59 |
60 | return latestEncounter;
61 | }
62 | return null;
63 | },
64 | successMessage:
65 | "Patient data pre-filled from the latest encounter.",
66 | errorMessage: "No patient data found",
67 | });
68 | },
69 |
70 | fetchPatientDetails: async (patientId, setters) => {
71 | return handleApiRequest({
72 | apiCall: () => fetch(`/api/patient/id/${patientId}`),
73 | onSuccess: (patientData) => {
74 | if (setters.setPatient) {
75 | setters.setPatient(patientData);
76 | }
77 | if (setters.setSelectedDate && setters.isFromOutstandingJobs) {
78 | setters.setSelectedDate(patientData.encounter_date);
79 | setters.setIsFromOutstandingJobs(false);
80 | }
81 | console.log(patientData);
82 | return patientData;
83 | },
84 | errorMessage: "Failed to fetch patient details",
85 | });
86 | },
87 |
88 | updateJobsList: async (patientId, jobsList) => {
89 | return handleApiRequest({
90 | apiCall: () =>
91 | fetch(`/api/patient/update-jobs-list`, {
92 | method: "POST",
93 | headers: { "Content-Type": "application/json" },
94 | body: JSON.stringify({ patientId, jobsList }),
95 | }),
96 | errorMessage: "Failed to update jobs list",
97 | });
98 | },
99 |
100 | generateReasoning: async (patientId, toast) => {
101 | return handleApiRequest({
102 | apiCall: () =>
103 | fetch(`/api/patient/${patientId}/reasoning`, {
104 | method: "POST",
105 | headers: { "Content-Type": "application/json" },
106 | }),
107 | successMessage: "Clinical reasoning generated successfully.",
108 | errorMessage: "Error running clinical reasoning",
109 | toast,
110 | });
111 | },
112 | };
113 |
--------------------------------------------------------------------------------
/src/utils/api/ragApi.js:
--------------------------------------------------------------------------------
1 | // API functions for RAG related operations.
2 | import { handleApiRequest } from "../helpers/apiHelpers";
3 |
4 | export const ragApi = {
5 | fetchCollections: () => {
6 | return handleApiRequest({
7 | apiCall: () => fetch("/api/rag/files"),
8 | errorMessage: "Failed to fetch collections",
9 | });
10 | },
11 |
12 | fetchCollectionFiles: (collectionName) => {
13 | return handleApiRequest({
14 | apiCall: () => fetch(`/api/rag/collection_files/${collectionName}`),
15 | errorMessage: `Error loading files for ${collectionName}`,
16 | });
17 | },
18 |
19 | renameCollection: (oldName, newName) => {
20 | return handleApiRequest({
21 | apiCall: () =>
22 | fetch("/api/rag/modify", {
23 | method: "POST",
24 | headers: { "Content-Type": "application/json" },
25 | body: JSON.stringify({
26 | old_name: oldName,
27 | new_name: newName,
28 | }),
29 | }),
30 | successMessage: `Successfully renamed to ${newName}`,
31 | errorMessage: "Failed to rename collection",
32 | });
33 | },
34 |
35 | deleteCollection: (collectionName) => {
36 | return handleApiRequest({
37 | apiCall: () =>
38 | fetch(`/api/rag/delete-collection/${collectionName}`, {
39 | method: "DELETE",
40 | }),
41 | successMessage: `Successfully deleted ${collectionName}`,
42 | errorMessage: "Failed to delete collection",
43 | });
44 | },
45 |
46 | deleteFile: (collectionName, fileName) => {
47 | return handleApiRequest({
48 | apiCall: () =>
49 | fetch("/api/rag/delete-file", {
50 | method: "DELETE",
51 | headers: { "Content-Type": "application/json" },
52 | body: JSON.stringify({
53 | collection_name: collectionName,
54 | file_name: fileName,
55 | }),
56 | }),
57 | successMessage: `Successfully deleted ${fileName}`,
58 | errorMessage: "Failed to delete file",
59 | });
60 | },
61 |
62 | extractPdfInfo: (formData) => {
63 | return handleApiRequest({
64 | apiCall: () =>
65 | fetch("/api/rag/extract-pdf-info", {
66 | method: "POST",
67 | body: formData,
68 | }),
69 | errorMessage: "Failed to extract PDF information",
70 | });
71 | },
72 |
73 | commitToDatabase: (data) => {
74 | return handleApiRequest({
75 | apiCall: () =>
76 | fetch("/api/rag/commit-to-vectordb", {
77 | method: "POST",
78 | headers: { "Content-Type": "application/json" },
79 | body: JSON.stringify(data),
80 | }),
81 | successMessage: "Successfully committed to database",
82 | errorMessage: "Failed to commit data to database",
83 | });
84 | },
85 | };
86 |
--------------------------------------------------------------------------------
/src/utils/api/templateApi.js:
--------------------------------------------------------------------------------
1 | import { handleApiRequest } from "../helpers/apiHelpers";
2 |
3 | export const templateApi = {
4 | fetchTemplates: () =>
5 | handleApiRequest({
6 | apiCall: () => fetch("/api/templates"),
7 | errorMessage: "Failed to fetch templates",
8 | }),
9 |
10 | getDefaultTemplate: () =>
11 | handleApiRequest({
12 | apiCall: () => fetch("/api/templates/default"),
13 | errorMessage: "Failed to fetch default template",
14 | }),
15 |
16 | getTemplateByKey: (templateKey) =>
17 | handleApiRequest({
18 | apiCall: () => fetch(`/api/templates/${templateKey}`),
19 | errorMessage: `Failed to fetch template: ${templateKey}`,
20 | }),
21 |
22 | setDefaultTemplate: (templateKey) =>
23 | handleApiRequest({
24 | apiCall: () =>
25 | fetch(`/api/templates/default/${templateKey}`, {
26 | method: "POST",
27 | }),
28 | successMessage: "Default template updated successfully",
29 | errorMessage: "Failed to set default template",
30 | }),
31 |
32 | saveTemplates: (templates) =>
33 | handleApiRequest({
34 | apiCall: () => {
35 | const templatesArray = Array.isArray(templates)
36 | ? templates
37 | : Object.values(templates);
38 |
39 | return fetch("/api/templates", {
40 | method: "POST",
41 | headers: { "Content-Type": "application/json" },
42 | body: JSON.stringify(templatesArray),
43 | });
44 | },
45 | successMessage: "Templates saved successfully",
46 | errorMessage: "Failed to save templates",
47 | transformResponse: (data) => ({
48 | message: data.message,
49 | details: data.details,
50 | updated_keys: data.updated_keys,
51 | }),
52 | }),
53 | };
54 |
--------------------------------------------------------------------------------
/src/utils/api/transcriptionApi.js:
--------------------------------------------------------------------------------
1 | import { handleApiRequest } from "../helpers/apiHelpers";
2 |
3 | export const transcriptionApi = {
4 | transcribeAudio: async (formData) => {
5 | return handleApiRequest({
6 | apiCall: () =>
7 | fetch(`/api/transcribe/audio`, {
8 | method: "POST",
9 | body: formData,
10 | }),
11 | errorMessage: "Error transcribing audio",
12 | });
13 | },
14 |
15 | reprocessTranscription: async (formData) => {
16 | return handleApiRequest({
17 | apiCall: () =>
18 | fetch(`/api/transcribe/reprocess`, {
19 | method: "POST",
20 | body: formData,
21 | }),
22 | errorMessage: "Error reprocessing transcription",
23 | });
24 | },
25 |
26 | transcribeDictation: async (formData) => {
27 | return handleApiRequest({
28 | apiCall: () =>
29 | fetch(`/api/transcribe/dictate`, {
30 | method: "POST",
31 | body: formData,
32 | }),
33 | errorMessage: "Error transcribing dictation",
34 | });
35 | },
36 |
37 | processDocument: async (formData) => {
38 | return handleApiRequest({
39 | apiCall: () =>
40 | fetch(`/api/transcribe/process-document`, {
41 | method: "POST",
42 | body: formData,
43 | }),
44 | errorMessage: "Error processing document",
45 | });
46 | },
47 | };
48 |
--------------------------------------------------------------------------------
/src/utils/chat/messageParser.js:
--------------------------------------------------------------------------------
1 | import { React } from "react";
2 |
3 | /**
4 | * Parses a message string for tags, extracting sections before, inside, and after the tag.
5 | * Supports streaming scenarios by handling unclosed tags.
6 | *
7 | * @param {string} content - The full message content to parse.
8 | * @returns {object} An object describing parsed sections:
9 | * - hasThinkTag: boolean
10 | * - beforeContent: string (text before the think block)
11 | * - thinkContent: string (text inside the think block)
12 | * - afterContent: string (text after the think block)
13 | * - isPartialThinking?: boolean (true if think block is not closed)
14 | */
15 | export const parseMessageContent = (content) => {
16 | const thinkRegex = /(.*?)<\/think>/s;
17 | const match = content.match(thinkRegex);
18 |
19 | if (match) {
20 | const thinkContent = match[1].trim();
21 | const parts = content.split(match[0]);
22 | const beforeContent = parts[0].trim();
23 | const afterContent = parts.slice(1).join(match[0]).trim();
24 |
25 | return {
26 | hasThinkTag: true,
27 | beforeContent,
28 | thinkContent,
29 | afterContent,
30 | };
31 | }
32 |
33 | // Change this regex to be greedy and handle the trimming more carefully
34 | const openThinkMatch = content.match(/(.*)$/s);
35 | if (openThinkMatch) {
36 | const beforeContent = content.split("")[0].trim();
37 | // Don't trim the partial content - preserve whitespace during streaming
38 | const partialThinkContent = openThinkMatch[1];
39 |
40 | return {
41 | hasThinkTag: true,
42 | beforeContent,
43 | thinkContent: partialThinkContent,
44 | afterContent: "",
45 | isPartialThinking: true,
46 | };
47 | }
48 |
49 | return {
50 | hasThinkTag: false,
51 | content,
52 | };
53 | };
54 |
--------------------------------------------------------------------------------
/src/utils/chat/messageUtils.js:
--------------------------------------------------------------------------------
1 | // Utility functions for formatting, validating, and truncating chat messages according to token count.
2 | import { encodingForModel } from "js-tiktoken";
3 |
4 | const enc = encodingForModel("gpt-4o");
5 |
6 | export const validateInput = (userInput) => {
7 | return userInput.trim() !== "";
8 | };
9 |
10 | export const formatInitialMessage = (template, patientData) => {
11 | if (!patientData || !template) {
12 | console.error(
13 | "Patient data and template are required for chat context",
14 | {
15 | hasPatientData: !!patientData,
16 | hasTemplate: !!template,
17 | },
18 | );
19 | return null;
20 | }
21 |
22 | // Check if template has fields
23 | if (!template.fields || !Array.isArray(template.fields)) {
24 | console.error("Template fields are not properly loaded:", template);
25 | return null;
26 | }
27 |
28 | const { template_data } = patientData;
29 |
30 | if (!template_data) {
31 | console.error("No template data available");
32 | return null;
33 | }
34 |
35 | // Create content array with template fields
36 | const contentArray = template.fields
37 | .map((field) => {
38 | const value =
39 | template_data[field.field_key] ||
40 | `No ${field.field_name.toLowerCase()} available`;
41 | return [
42 | `${field.field_name}:`,
43 | value,
44 | "", // Add empty line for spacing
45 | ];
46 | })
47 | .flat();
48 |
49 | return {
50 | role: "user",
51 | content: contentArray.join("\n"),
52 | };
53 | };
54 |
55 | export const truncateConversationHistory = (messages) => {
56 | let conversationHistoryStr = messages
57 | .map((msg) => `${msg.role}: ${msg.content}`)
58 | .join(" ");
59 | let conversationHistoryTokenCount = enc.encode(
60 | conversationHistoryStr,
61 | ).length;
62 |
63 | while (conversationHistoryTokenCount > 5000 && messages.length > 2) {
64 | messages.splice(1, 2);
65 | conversationHistoryStr = messages
66 | .map((msg) => `${msg.role}: ${msg.content}`)
67 | .join(" ");
68 | conversationHistoryTokenCount = enc.encode(
69 | conversationHistoryStr,
70 | ).length;
71 | }
72 |
73 | return messages;
74 | };
75 |
--------------------------------------------------------------------------------
/src/utils/constants/index.js:
--------------------------------------------------------------------------------
1 | // Application-wide constants, such as API endpoints, default configurations, and data defaults
2 | export const API_ENDPOINTS = {
3 | PATIENT: {
4 | SAVE: "/api/save-patient",
5 | SEARCH: "/api/search-patient",
6 | DETAILS: "/api/patient",
7 | LETTER: "/api/fetch-letter",
8 | SAVE_LETTER: "/api/save-letter",
9 | UPDATE_JOBS: "/api/update-jobs-list",
10 | },
11 | CHAT: {
12 | SEND: "/api/chat",
13 | GENERATE_LETTER: "/api/generate-letter",
14 | },
15 | RAG: {
16 | FILES: "/api/rag/files",
17 | COLLECTION_FILES: "/api/rag/collection_files",
18 | MODIFY: "/api/rag/modify",
19 | DELETE_COLLECTION: "/api/rag/delete-collection",
20 | DELETE_FILE: "/api/rag/delete-file",
21 | EXTRACT_PDF: "/api/rag/extract-pdf-info",
22 | COMMIT: "/api/rag/commit-to-vectordb",
23 | },
24 | SETTINGS: {
25 | USER: "/api/user-settings",
26 | PROMPTS: "/api/prompts",
27 | CONFIG: "/api/config",
28 | OPTIONS: "/api/options",
29 | },
30 | };
31 |
32 | export const DEFAULT_TOAST_CONFIG = {
33 | duration: 3000,
34 | isClosable: true,
35 | position: "bottom",
36 | };
37 |
38 | export const MODEL_DEFAULTS = {
39 | PRIMARY: {
40 | num_ctx: 2048,
41 | temperature: 0.7,
42 | },
43 | SECONDARY: {
44 | num_ctx: 1024,
45 | temperature: 0.5,
46 | },
47 | };
48 |
49 | export const SPECIALTIES = [
50 | "Anaesthetics",
51 | "Cardiology",
52 | "Dermatology",
53 | "Emergency Medicine",
54 | "Endocrinology",
55 | "Family Medicine",
56 | "Gastroenterology",
57 | "General Practice",
58 | "General Surgery",
59 | "Geriatrics",
60 | "Haematology",
61 | "Internal Medicine",
62 | "Neurology",
63 | "Obstetrics and Gynaecology",
64 | "Oncology",
65 | "Ophthalmology",
66 | "Orthopaedics",
67 | "Paediatrics",
68 | "Psychiatry",
69 | "Radiology",
70 | "Respiratory Medicine",
71 | "Rheumatology",
72 | "Urology",
73 | ];
74 |
--------------------------------------------------------------------------------
/src/utils/helpers/apiHelpers.js:
--------------------------------------------------------------------------------
1 | // Helper functions for common API request handling.
2 | export const handleApiRequest = async ({
3 | apiCall,
4 | setLoading = null,
5 | onSuccess = null,
6 | onError = null,
7 | successMessage = null,
8 | errorMessage = null,
9 | toast = null,
10 | finallyCallback = null,
11 | }) => {
12 | if (setLoading) setLoading(true);
13 |
14 | try {
15 | const response = await apiCall();
16 |
17 | if (!response.ok) {
18 | throw new Error(`HTTP error! status: ${response.status}`);
19 | }
20 |
21 | const data = await response.json();
22 |
23 | if (onSuccess) {
24 | onSuccess(data);
25 | }
26 |
27 | if (successMessage && toast) {
28 | toast({
29 | title: "Success",
30 | description: successMessage,
31 | status: "success",
32 | duration: 3000,
33 | isClosable: true,
34 | });
35 | }
36 |
37 | return data;
38 | } catch (error) {
39 | console.error("API Error:", error);
40 |
41 | if (onError) {
42 | onError(error);
43 | }
44 |
45 | if (toast) {
46 | toast({
47 | title: "Error",
48 | description: errorMessage || error.message,
49 | status: "error",
50 | duration: 5000,
51 | isClosable: true,
52 | });
53 | }
54 |
55 | throw error;
56 | } finally {
57 | if (setLoading) setLoading(false);
58 | if (finallyCallback) finallyCallback();
59 | }
60 | };
61 |
62 | // Utility function for handling form data
63 | export const createFormData = (data) => {
64 | const formData = new FormData();
65 | Object.entries(data).forEach(([key, value]) => {
66 | if (value !== null && value !== undefined) {
67 | formData.append(key, value);
68 | }
69 | });
70 | return formData;
71 | };
72 |
73 | // Utility function for handling query parameters
74 | export const createQueryString = (params) => {
75 | return Object.entries(params)
76 | .filter(([_, value]) => value !== null && value !== undefined)
77 | .map(
78 | ([key, value]) =>
79 | `${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
80 | )
81 | .join("&");
82 | };
83 |
--------------------------------------------------------------------------------
/src/utils/helpers/errorHandlers.js:
--------------------------------------------------------------------------------
1 | // Functions to handle and format API errors.
2 | import { DEFAULT_TOAST_CONFIG } from "../constants";
3 |
4 | export class ApiError extends Error {
5 | constructor(message, status) {
6 | super(message);
7 | this.status = status;
8 | this.name = "ApiError";
9 | }
10 | }
11 |
12 | export const handleError = (error, toast) => {
13 | console.error("Error:", error);
14 |
15 | if (error instanceof ApiError) {
16 | toast({
17 | title: `Error ${error.status}`,
18 | description: error.message,
19 | status: "error",
20 | ...DEFAULT_TOAST_CONFIG,
21 | });
22 | } else {
23 | toast({
24 | title: "Error",
25 | description: "An unexpected error occurred",
26 | status: "error",
27 | ...DEFAULT_TOAST_CONFIG,
28 | });
29 | }
30 | };
31 |
--------------------------------------------------------------------------------
/src/utils/helpers/formatHelpers.js:
--------------------------------------------------------------------------------
1 | // Utility functions for formatting names and dates
2 | export const formatCollectionName = (name) => {
3 | return name
4 | .replace(/_/g, " ")
5 | .replace(/\b\w/g, (char) => char.toUpperCase());
6 | };
7 |
8 | export const formatPatientName = (name) => {
9 | const nameParts = name.split(", ");
10 | const firstNameInitial = nameParts[1] ? nameParts[1][0] : "";
11 | const lastName = nameParts[0];
12 | return `${firstNameInitial}. ${lastName}`;
13 | };
14 |
15 | export const formatDate = (date) => {
16 | if (!date) return "";
17 | return new Date(date).toLocaleDateString("en-US", {
18 | year: "numeric",
19 | month: "long",
20 | day: "numeric",
21 | });
22 | };
23 |
--------------------------------------------------------------------------------
/src/utils/helpers/loadingHelpers.js:
--------------------------------------------------------------------------------
1 | // Helper functions to manage loading states with toast notifications
2 | export const withLoading = async (loadingFn, setLoading) => {
3 | setLoading(true);
4 | try {
5 | await loadingFn();
6 | } finally {
7 | setLoading(false);
8 | }
9 | };
10 |
11 | export const createLoadingToast = (toast, message = "Loading...") => {
12 | return toast({
13 | title: message,
14 | status: "info",
15 | duration: null,
16 | isClosable: false,
17 | });
18 | };
19 |
--------------------------------------------------------------------------------
/src/utils/helpers/processingHelpers.js:
--------------------------------------------------------------------------------
1 | // Helper functions for handling processing of documents and transcriptions.
2 |
3 | export const handleProcessingComplete = (
4 | data,
5 | {
6 | setLoading,
7 | setters = {},
8 | setIsSourceCollapsed,
9 | setIsSummaryCollapsed,
10 | triggerResize = false,
11 | summaryRef = null,
12 | },
13 | ) => {
14 | setLoading(false);
15 |
16 | // Special handling for template_data since it's nested in fields
17 | if (setters.template_data && data.fields) {
18 | setters.template_data(data.fields);
19 | }
20 |
21 | // Handle other setters
22 | Object.entries(setters).forEach(([key, setter]) => {
23 | if (key !== "template_data" && data[key] !== undefined && setter) {
24 | setter(data[key]);
25 | }
26 | });
27 |
28 | // Handle UI collapse states
29 | if (setIsSourceCollapsed) {
30 | setIsSourceCollapsed(true);
31 | }
32 | if (setIsSummaryCollapsed) {
33 | setIsSummaryCollapsed(false);
34 | }
35 |
36 | // Only try to resize if we have a valid ref and it has the method
37 | if (triggerResize && summaryRef?.current?.resizeTextarea) {
38 | setTimeout(() => {
39 | summaryRef.current.resizeTextarea();
40 | }, 0);
41 | }
42 | };
43 |
44 | export const processDocument = async (
45 | file,
46 | patientDetails,
47 | onSuccess,
48 | onError,
49 | ) => {
50 | const formData = new FormData();
51 | formData.append("file", file);
52 |
53 | // Add patient details to formData if they exist
54 | Object.entries(patientDetails).forEach(([key, value]) => {
55 | if (value) formData.append(key, value);
56 | });
57 |
58 | try {
59 | const response = await fetch("/api/transcribe/process-document", {
60 | method: "POST",
61 | body: formData,
62 | });
63 |
64 | if (!response.ok) {
65 | throw new Error("Document processing failed");
66 | }
67 |
68 | const data = await response.json();
69 | onSuccess(data);
70 | return data;
71 | } catch (error) {
72 | console.error("Error processing document:", error);
73 | onError(error);
74 | throw error;
75 | }
76 | };
77 |
--------------------------------------------------------------------------------
/src/utils/helpers/settingsHelpers.js:
--------------------------------------------------------------------------------
1 | // Helper functions for settings component
2 | export const settingsHelpers = {
3 | processOptionsData: (data) => ({
4 | general: {
5 | num_ctx: data?.general?.num_ctx || 0,
6 | },
7 | secondary: {
8 | num_ctx: data?.secondary?.num_ctx || 0,
9 | },
10 | letter: {
11 | temperature: data?.letter?.temperature || 0,
12 | },
13 | reasoning: {
14 | temperature: data?.reasoning?.temperature || 0,
15 | num_ctx: data?.reasoning?.num_ctx || 0,
16 | },
17 | }),
18 |
19 | showSuccessToast: (toast, message) => {
20 | if (toast) {
21 | toast({
22 | title: "Success",
23 | description: message,
24 | status: "success",
25 | duration: 3000,
26 | isClosable: true,
27 | });
28 | }
29 | },
30 |
31 | showErrorToast: (toast, message) => {
32 | if (toast) {
33 | toast({
34 | title: "Error",
35 | description: message,
36 | status: "error",
37 | duration: 3000,
38 | isClosable: true,
39 | });
40 | }
41 | },
42 |
43 | ensureTemplatesArray: (templates) => {
44 | if (Array.isArray(templates)) return templates;
45 | if (typeof templates === "object") return Object.values(templates);
46 | return [];
47 | },
48 | };
49 |
--------------------------------------------------------------------------------
/src/utils/helpers/validationHelpers.js:
--------------------------------------------------------------------------------
1 | // Helper functions for validating data before submission.
2 | export const validatePatientData = (patientData) => {
3 | const requiredFields = ["name", "dob", "ur_number", "gender"];
4 | const missingFields = requiredFields.filter((field) => !patientData[field]);
5 |
6 | if (missingFields.length > 0) {
7 | throw new Error(`Missing required fields: ${missingFields.join(", ")}`);
8 | }
9 |
10 | return true;
11 | };
12 |
13 | export const validateLetterData = (letterData) => {
14 | const validations = {
15 | patientName: (val) => typeof val === "string" && val.length > 0,
16 | gender: (val) => ["M", "F"],
17 | };
18 |
19 | Object.entries(validations).forEach(([field, validator]) => {
20 | if (!validator(letterData[field])) {
21 | throw new Error(`Invalid ${field}`);
22 | }
23 | });
24 |
25 | return true;
26 | };
27 |
--------------------------------------------------------------------------------
/src/utils/hooks/UseToastMessage.js:
--------------------------------------------------------------------------------
1 | // Custom hook for managing toast notifications.
2 | import { useToast } from "@chakra-ui/react";
3 | import { DEFAULT_TOAST_CONFIG } from "../constants";
4 |
5 | export const useToastMessage = () => {
6 | const toast = useToast();
7 |
8 | const showSuccessToast = (message) => {
9 | toast({
10 | title: "Success",
11 | description: message,
12 | status: "success",
13 | ...DEFAULT_TOAST_CONFIG,
14 | });
15 | };
16 |
17 | const showErrorToast = (message) => {
18 | toast({
19 | title: "Error",
20 | description: message,
21 | status: "error",
22 | ...DEFAULT_TOAST_CONFIG,
23 | });
24 | };
25 |
26 | const showWarningToast = (message) => {
27 | toast({
28 | title: "Warning",
29 | description: message,
30 | status: "warning",
31 | ...DEFAULT_TOAST_CONFIG,
32 | });
33 | };
34 |
35 | return { showSuccessToast, showErrorToast, showWarningToast };
36 | };
37 |
--------------------------------------------------------------------------------
/src/utils/hooks/useCollapse.js:
--------------------------------------------------------------------------------
1 | // Custom hook for managing collapse/expand state of a component.
2 | import { useState } from "react";
3 |
4 | export const useCollapse = (initialState = true) => {
5 | const [isCollapsed, setIsCollapsed] = useState(initialState);
6 |
7 | const toggle = () => setIsCollapsed((prev) => !prev);
8 |
9 | return {
10 | isCollapsed,
11 | setIsCollapsed,
12 | toggle,
13 | };
14 | };
15 |
--------------------------------------------------------------------------------
/src/utils/hooks/useLetterTemplates.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 | import { letterApi } from "../api/letterApi";
3 | import { settingsApi } from "../api/settingsApi";
4 |
5 | export const useLetterTemplates = (patientId) => {
6 | const [letterTemplates, setLetterTemplates] = useState([]);
7 | const [defaultTemplateId, setDefaultTemplateId] = useState(null);
8 | const [selectedTemplate, setSelectedTemplate] = useState(null);
9 | const [additionalInstructions, setAdditionalInstructions] = useState("");
10 | const [options, setOptions] = useState(null);
11 |
12 | // Fetch options (settings)
13 | useEffect(() => {
14 | const fetchOptions = async () => {
15 | try {
16 | const response = await settingsApi.fetchOptions();
17 | setOptions(response);
18 | } catch (error) {
19 | console.error("Failed to fetch options:", error);
20 | }
21 | };
22 | fetchOptions();
23 | }, []);
24 |
25 | // Fetch letter templates
26 | useEffect(() => {
27 | if (!patientId) return;
28 |
29 | letterApi
30 | .fetchLetterTemplates()
31 | .then((response) => {
32 | setLetterTemplates(response.templates);
33 | if (response.default_template_id) {
34 | setDefaultTemplateId(response.default_template_id);
35 |
36 | // Reset to default template when patient changes or component mounts
37 | const defaultTpl = response.templates.find(
38 | (t) => t.id === response.default_template_id,
39 | );
40 | if (defaultTpl) {
41 | setSelectedTemplate(defaultTpl);
42 | // Set initial instructions from default template
43 | setAdditionalInstructions(
44 | defaultTpl.instructions || "",
45 | );
46 | }
47 | }
48 | })
49 | .catch((err) =>
50 | console.error("Error fetching letter templates:", err),
51 | );
52 | }, [patientId]);
53 |
54 | const selectTemplate = (template) => {
55 | if (template === "custom") {
56 | setSelectedTemplate("custom");
57 | } else {
58 | setSelectedTemplate(template);
59 | setAdditionalInstructions(template.instructions || "");
60 | }
61 | };
62 |
63 | const getInstructions = () => {
64 | if (selectedTemplate === "custom") {
65 | return additionalInstructions;
66 | } else if (selectedTemplate && selectedTemplate.instructions) {
67 | return selectedTemplate.instructions;
68 | } else if (!selectedTemplate && defaultTemplateId) {
69 | const defaultTpl = letterTemplates.find(
70 | (t) => t.id === defaultTemplateId,
71 | );
72 | return defaultTpl
73 | ? defaultTpl.instructions
74 | : additionalInstructions;
75 | }
76 | return additionalInstructions;
77 | };
78 |
79 | return {
80 | letterTemplates,
81 | defaultTemplateId,
82 | selectedTemplate,
83 | additionalInstructions,
84 | setAdditionalInstructions,
85 | options,
86 | selectTemplate,
87 | getInstructions,
88 | };
89 | };
90 |
--------------------------------------------------------------------------------
/src/utils/hooks/useSettings.js:
--------------------------------------------------------------------------------
1 | // Custom hook for managing application settings state.
2 | import { useState, useEffect } from "react";
3 | import { settingsService } from "../settings/ettingsUtils";
4 |
5 | export const useSettings = () => {
6 | const [settings, setSettings] = useState(null);
7 | const [loading, setLoading] = useState(true);
8 | const [error, setError] = useState(null);
9 |
10 | useEffect(() => {
11 | const loadSettings = async () => {
12 | try {
13 | const [userSettings, prompts, config, options] =
14 | await Promise.all([
15 | settingsService.fetchUserSettings(),
16 | settingsService.fetchPrompts(),
17 | settingsService.fetchConfig(),
18 | settingsService.fetchOptions(),
19 | ]);
20 |
21 | setSettings({ userSettings, prompts, config, options });
22 | } catch (err) {
23 | setError(err);
24 | } finally {
25 | setLoading(false);
26 | }
27 | };
28 |
29 | loadSettings();
30 | }, []);
31 |
32 | return { settings, loading, error };
33 | };
34 |
--------------------------------------------------------------------------------
/src/utils/letter/letterUtils.js:
--------------------------------------------------------------------------------
1 | import { encodingForModel } from "js-tiktoken";
2 | import { letterApi } from "../api/letterApi";
3 |
4 | const enc = encodingForModel("gpt-4o");
5 |
6 | export const truncateLetterContext = (messages, maxTokens) => {
7 | // Helper to count tokens for a single message
8 | const countTokens = (msg) => {
9 | return enc.encode(`${msg.role}: ${msg.content}`).length;
10 | };
11 |
12 | // Calculate total tokens
13 | let totalTokens = messages.reduce((sum, msg) => sum + countTokens(msg), 0);
14 |
15 | // Create a working copy of messages
16 | let workingMessages = [...messages];
17 |
18 | // Keep removing pairs until we're under the token limit
19 | while (totalTokens > maxTokens && workingMessages.length > 2) {
20 | // Find the first assistant message
21 | const firstAssistantIndex = workingMessages.findIndex(
22 | (msg) => msg.role === "assistant",
23 | );
24 |
25 | // Find the second assistant message (if it exists)
26 | const secondAssistantIndex = workingMessages.findIndex(
27 | (msg, index) =>
28 | msg.role === "assistant" && index > firstAssistantIndex,
29 | );
30 |
31 | if (secondAssistantIndex !== -1) {
32 | // If we have at least two assistant messages, remove everything between them
33 | // (which would be the assistant message and its corresponding user message)
34 | const removed = workingMessages.splice(
35 | firstAssistantIndex,
36 | secondAssistantIndex - firstAssistantIndex,
37 | );
38 | totalTokens -= removed.reduce(
39 | (sum, msg) => sum + countTokens(msg),
40 | 0,
41 | );
42 | } else {
43 | // If we only have one assistant message left, we're done truncating
44 | break;
45 | }
46 | }
47 |
48 | // Ensure we start with an assistant message
49 | const firstAssistantIndex = workingMessages.findIndex(
50 | (msg) => msg.role === "assistant",
51 | );
52 | if (firstAssistantIndex > 0) {
53 | // Remove any messages before the first assistant message
54 | workingMessages.splice(0, firstAssistantIndex);
55 | }
56 |
57 | return workingMessages;
58 | };
59 |
60 | // New utility functions
61 | export const autoResizeTextarea = (textarea) => {
62 | if (textarea) {
63 | textarea.style.height = "auto";
64 | textarea.style.height = textarea.scrollHeight + "px";
65 | }
66 | };
67 |
68 | export const formatLetterContent = (content) => {
69 | if (!content) return "No letter attached to encounter";
70 | return content;
71 | };
72 |
73 | export const getDefaultInstructions = async () => {
74 | try {
75 | const responseTemplates = await letterApi.fetchLetterTemplates();
76 | if (responseTemplates && responseTemplates.default_template_id) {
77 | const defaultTemplate = responseTemplates.templates.find(
78 | (tpl) => tpl.id === responseTemplates.default_template_id,
79 | );
80 | if (defaultTemplate) {
81 | return defaultTemplate.instructions || "";
82 | }
83 | }
84 | return "";
85 | } catch (error) {
86 | console.error("Error getting default instructions:", error);
87 | return "";
88 | }
89 | };
90 |
--------------------------------------------------------------------------------
/src/utils/services/templateService.js:
--------------------------------------------------------------------------------
1 | import { settingsApi } from "../api/settingsApi";
2 | import { settingsHelpers } from "../helpers/settingsHelpers";
3 | const templateCache = new Map();
4 | export const templateService = {
5 | fetchTemplates: async () => {
6 | try {
7 | const response = await fetch("/api/templates");
8 | if (!response.ok) {
9 | throw new Error("Failed to fetch templates");
10 | }
11 | return await response.json();
12 | } catch (error) {
13 | console.error("Failed to fetch templates:", error);
14 | throw error;
15 | }
16 | },
17 |
18 | async getDefaultTemplate() {
19 | try {
20 | const response = await fetch("/api/templates/default");
21 | if (!response.ok) {
22 | throw new Error("Failed to fetch default template");
23 | }
24 | const data = await response.json();
25 | return data;
26 | } catch (error) {
27 | console.error("Failed to get default template:", error);
28 | throw error;
29 | }
30 | },
31 | setDefaultTemplate: async (templateKey, toast) => {
32 | try {
33 | await settingsApi.setDefaultTemplate(templateKey);
34 | if (toast) {
35 | settingsHelpers.showSuccessToast(
36 | toast,
37 | "Default template updated successfully",
38 | );
39 | }
40 | } catch (error) {
41 | if (toast) {
42 | settingsHelpers.showErrorToast(
43 | toast,
44 | "Failed to set default template",
45 | );
46 | }
47 | throw error;
48 | }
49 | },
50 |
51 | async getTemplateByKey(templateKey) {
52 | // Check cache first
53 | if (templateCache.has(templateKey)) {
54 | return templateCache.get(templateKey);
55 | }
56 |
57 | try {
58 | const response = await fetch(`/api/templates/${templateKey}`);
59 | if (!response.ok) {
60 | throw new Error("Failed to fetch template");
61 | }
62 | const template = await response.json();
63 |
64 | // Cache the template
65 | templateCache.set(templateKey, template);
66 |
67 | return template;
68 | } catch (error) {
69 | console.error(`Failed to fetch template ${templateKey}:`, error);
70 | throw error;
71 | }
72 | },
73 | };
74 |
--------------------------------------------------------------------------------
/src/utils/templates/templateService.js:
--------------------------------------------------------------------------------
1 | import { settingsApi } from "../api/settingsApi";
2 | import { settingsHelpers } from "../helpers/settingsHelpers";
3 | const templateCache = new Map();
4 | export const templateService = {
5 | fetchTemplates: async () => {
6 | try {
7 | const response = await fetch("/api/templates");
8 | if (!response.ok) {
9 | throw new Error("Failed to fetch templates");
10 | }
11 | return await response.json();
12 | } catch (error) {
13 | console.error("Failed to fetch templates:", error);
14 | throw error;
15 | }
16 | },
17 |
18 | async getDefaultTemplate() {
19 | try {
20 | const response = await fetch("/api/templates/default");
21 | if (!response.ok) {
22 | throw new Error("Failed to fetch default template");
23 | }
24 | const data = await response.json();
25 | return data;
26 | } catch (error) {
27 | console.error("Failed to get default template:", error);
28 | throw error;
29 | }
30 | },
31 |
32 | setDefaultTemplate: async (templateKey, toast) => {
33 | try {
34 | await settingsApi.setDefaultTemplate(templateKey);
35 | if (toast) {
36 | settingsHelpers.showSuccessToast(
37 | toast,
38 | "Default template updated successfully",
39 | );
40 | }
41 | } catch (error) {
42 | if (toast) {
43 | settingsHelpers.showErrorToast(
44 | toast,
45 | "Failed to set default template",
46 | );
47 | }
48 | throw error;
49 | }
50 | },
51 |
52 | async getTemplateByKey(templateKey) {
53 | // Check cache first
54 | if (templateCache.has(templateKey)) {
55 | return templateCache.get(templateKey);
56 | }
57 |
58 | try {
59 | const response = await fetch(`/api/templates/${templateKey}`);
60 | if (!response.ok) {
61 | throw new Error("Failed to fetch template");
62 | }
63 | const template = await response.json();
64 |
65 | // Cache the template
66 | templateCache.set(templateKey, template);
67 |
68 | return template;
69 | } catch (error) {
70 | console.error(`Failed to fetch template ${templateKey}:`, error);
71 | throw error;
72 | }
73 | },
74 |
75 | isDefaultTemplate: (templateKey) => {
76 | const DEFAULT_TEMPLATE_KEYS = ["phlox_", "soap_", "progress_"];
77 | return DEFAULT_TEMPLATE_KEYS.some((prefix) =>
78 | templateKey.startsWith(prefix),
79 | );
80 | },
81 |
82 | // Add a function to delete a template
83 | deleteTemplate: async (templateKey) => {
84 | try {
85 | const response = await fetch(`/api/templates/${templateKey}`, {
86 | method: "DELETE",
87 | });
88 |
89 | if (!response.ok) {
90 | const errorData = await response
91 | .json()
92 | .catch(() => ({ message: "Unknown error" }));
93 | throw new Error(
94 | errorData.message ||
95 | `Failed to delete template: ${response.status}`,
96 | );
97 | }
98 |
99 | // Remove from cache if it exists
100 | if (templateCache.has(templateKey)) {
101 | templateCache.delete(templateKey);
102 | }
103 |
104 | return true;
105 | } catch (error) {
106 | console.error(`Failed to delete template ${templateKey}:`, error);
107 | throw error;
108 | }
109 | },
110 | };
111 |
--------------------------------------------------------------------------------