Andrchest commited on
Commit
365de9c
·
1 Parent(s): 796983b

Single commit for HF2

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +7 -0
  2. .github/ISSUE_TEMPLATE/bug_report.md +26 -0
  3. .github/ISSUE_TEMPLATE/technical_task.md +23 -0
  4. .github/ISSUE_TEMPLATE/user_story.md +16 -0
  5. .github/PULL_REQUEST_TEMPLATE.md +33 -0
  6. .github/PULL_REQUEST_TEMPLATE/standart.md +33 -0
  7. .github/workflows/sync-to-hf.yml +99 -0
  8. .github/workflows/unit-tests.yml +51 -0
  9. .gitignore +9 -0
  10. .vscode/settings.json +5 -0
  11. CHANGELOG.md +39 -0
  12. CONTRIBUTING.md +0 -0
  13. Dockerfile +24 -0
  14. LICENSE +21 -0
  15. README.md +336 -1
  16. app/__init__.py +0 -0
  17. app/api/__init__.py +0 -0
  18. app/api/api.py +353 -0
  19. app/automigration.py +4 -0
  20. app/backend/__init__.py +0 -0
  21. app/backend/controllers/__init__.py +0 -0
  22. app/backend/controllers/base_controller.py +5 -0
  23. app/backend/controllers/chat_controller.py +0 -0
  24. app/backend/controllers/chats.py +113 -0
  25. app/backend/controllers/messages.py +18 -0
  26. app/backend/controllers/user_controller.py +113 -0
  27. app/backend/controllers/users.py +197 -0
  28. app/backend/models/__init__.py +0 -0
  29. app/backend/models/base_model.py +10 -0
  30. app/backend/models/chats.py +54 -0
  31. app/backend/models/db_service.py +30 -0
  32. app/backend/models/messages.py +25 -0
  33. app/backend/models/users.py +80 -0
  34. app/backend/schemas.py +45 -0
  35. app/core/__init__.py +0 -0
  36. app/core/chunks.py +54 -0
  37. app/core/database.py +217 -0
  38. app/core/document_validator.py +9 -0
  39. app/core/main.py +33 -0
  40. app/core/models.py +203 -0
  41. app/core/processor.py +284 -0
  42. app/core/rag_generator.py +173 -0
  43. app/core/response_parser.py +29 -0
  44. app/core/utils.py +200 -0
  45. app/email_templates/password_reset.html +80 -0
  46. app/frontend/static/styles.css +377 -0
  47. app/frontend/templates/base.html +42 -0
  48. app/frontend/templates/components/navbar.html +33 -0
  49. app/frontend/templates/components/sidebar.html +26 -0
  50. app/frontend/templates/pages/chat.html +163 -0
.gitattributes ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ *.jpg filter=lfs diff=lfs merge=lfs -text
2
+ *.png filter=lfs diff=lfs merge=lfs -text
3
+ *.gif filter=lfs diff=lfs merge=lfs -text
4
+ *.pdf filter=lfs diff=lfs merge=lfs -text
5
+ *.zip filter=lfs diff=lfs merge=lfs -text
6
+ *.bin filter=lfs diff=lfs merge=lfs -text
7
+ *.jpeg filter=lfs diff=lfs merge=lfs -text
.github/ISSUE_TEMPLATE/bug_report.md ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Bug Report
3
+ about: Template for reporting bugs with reproduction steps.
4
+ title: "BUG: [Brief Description]"
5
+ labels: ["bug"]
6
+ assignees: ""
7
+ ---
8
+
9
+ ## Current Behavior
10
+ *What actually happens?*
11
+
12
+ ## Expected Behavior
13
+ *What should happen instead?*
14
+
15
+ ## Steps to Reproduce
16
+ 1. Go to...
17
+ 2. Click on...
18
+ 3. Scroll to...
19
+ 4. See error
20
+
21
+ ## Environment
22
+ - OS: [e.g., Windows 11]
23
+ - Browser: [e.g., Chrome 120]
24
+ - Version: [e.g., v2.1.0]
25
+
26
+ ## Screenshots/Logs
.github/ISSUE_TEMPLATE/technical_task.md ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Technical Task
3
+ about: Template for technical tasks with subtasks.
4
+ title: "TECH TASK: [Brief Description]"
5
+ labels: ["refactor", "technical"]
6
+ assignees: ""
7
+ ---
8
+
9
+ ## Objective
10
+ *What technical goal does this achieve?*
11
+
12
+ ## Subtasks
13
+ - [ ] Subtask 1 (e.g., "Refactor X component")
14
+ - [ ] Subtask 2 (e.g., "Update dependency Y")
15
+ - [ ] Subtask 3 (e.g., "Write tests for Z")
16
+
17
+ OR
18
+
19
+ ## Linked Subtask Issues
20
+ - #45 (Refactor X)
21
+ - #46 (Update Y)
22
+
23
+ ## Implementation Notes
.github/ISSUE_TEMPLATE/user_story.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: User Story
3
+ about: Template for capturing user stories with GIVEN/WHEN/THEN acceptance criteria.
4
+ title: "USER STORY: [Brief Description]"
5
+ labels: ["feature"]
6
+ assignees: ""
7
+ ---
8
+
9
+ ## Description
10
+ *As a [user role], I want [goal] so that [benefit].*
11
+
12
+ ## Acceptance Criteria (GIVEN/WHEN/THEN)
13
+ ```gherkin
14
+ GIVEN [initial context]
15
+ WHEN [action/event occurs]
16
+ THEN [expected outcome]
.github/PULL_REQUEST_TEMPLATE.md ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Standard Pull Request
3
+ description: Template for all PRs with mandatory issue linking and change documentation.
4
+ title: "PR: [Brief Description]"
5
+ labels: ["needs-review"]
6
+ assignees: ""
7
+ ---
8
+
9
+ ## Linked Issue
10
+ <!-- Mandatory: Link to the issue this PR resolves -->
11
+ Fixes #123
12
+ *(or use `Closes #123`, `Resolves #123`)*
13
+
14
+ ## Changes Proposed
15
+ <!-- Describe the changes in this PR -->
16
+ - Added [feature X]
17
+ - Fixed [bug Y]
18
+ - Refactored [component Z]
19
+
20
+ ## Testing Done
21
+ <!-- How was this tested? Include steps or environment details -->
22
+ - [ ] Unit tests
23
+ - [ ] Manual testing (steps: 1. ... 2. ...)
24
+ - Tested on: [OS/Browser]
25
+
26
+ ## Screenshots (if UI changes)
27
+ <!-- Drag & drop images -->
28
+
29
+ ## Checklist
30
+ - [ ] Code follows project style guidelines
31
+ - [ ] Documentation updated (if needed)
32
+ - [ ] Branch is up-to-date with `main`
33
+ - [ ] Reviewer(s) assigned
.github/PULL_REQUEST_TEMPLATE/standart.md ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Standard Pull Request
3
+ description: Template for all PRs with mandatory issue linking and change documentation.
4
+ title: "PR: [Brief Description]"
5
+ labels: ["needs-review"]
6
+ assignees: ""
7
+ ---
8
+
9
+ ## Linked Issue
10
+ <!-- Mandatory: Link to the issue this PR resolves -->
11
+ Fixes #123
12
+ *(or use `Closes #123`, `Resolves #123`)*
13
+
14
+ ## Changes Proposed
15
+ <!-- Describe the changes in this PR -->
16
+ - Added [feature X]
17
+ - Fixed [bug Y]
18
+ - Refactored [component Z]
19
+
20
+ ## Testing Done
21
+ <!-- How was this tested? Include steps or environment details -->
22
+ - [ ] Unit tests
23
+ - [ ] Manual testing (steps: 1. ... 2. ...)
24
+ - Tested on: [OS/Browser]
25
+
26
+ ## Screenshots (if UI changes)
27
+ <!-- Drag & drop images -->
28
+
29
+ ## Checklist
30
+ - [ ] Code follows project style guidelines
31
+ - [ ] Documentation updated (if needed)
32
+ - [ ] Branch is up-to-date with `main`
33
+ - [ ] Reviewer(s) assigned
.github/workflows/sync-to-hf.yml ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Sync to Hugging Face Hub
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ sync-to-hub:
10
+ runs-on: ubuntu-latest
11
+ environment: Integration test
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ with:
15
+ fetch-depth: 0
16
+ lfs: true
17
+
18
+ - name: Configure Git identity
19
+ run: |
20
+ git config --global user.name "Andrchest"
21
+ git config --global user.email "andreipolevoi220@gmail.com"
22
+
23
+ - name: Set up Git LFS
24
+ run: |
25
+ git lfs install
26
+ git lfs track "*.jpeg" "*.jpg" "*.png" "*.gif" "*.pdf" "*.zip" "*.bin"
27
+ git add .gitattributes
28
+ git add .
29
+ git commit -m "Add LFS tracking for binary files" || true
30
+
31
+ - name: Create .env file
32
+ run: |
33
+ echo "DATABASE_URL=${{ secrets.DATABASE_URL }}" >> .env
34
+ echo "GEMINI_API_KEY=${{ secrets.GEMINI_API_KEY }}" >> .env
35
+ echo "SECRET_PEPPER=${{ secrets.SECRET_PEPPER }}" >> .env
36
+ echo "JWT_ALGORITHM=${{ secrets.JWT_ALGORITHM }}" >> .env
37
+ echo "HF1_URL=${{ secrets.HF1_URL }}" >> .env
38
+
39
+ - name: Build Docker image
40
+ run: docker build -t my-app .
41
+
42
+ - name: Run Docker container
43
+ run: docker run -d --name my-app-container -v $(pwd)/.env:/app/.env -p 7860:7860 my-app
44
+
45
+ - name: Wait for app to be ready
46
+ run: |
47
+ for i in {1..10}; do
48
+ if curl -s http://localhost:7860/health | grep -q "ok"; then
49
+ echo "App is ready!"
50
+ break
51
+ fi
52
+ echo "Waiting for app..."
53
+ sleep 10
54
+ done
55
+
56
+ - name: Install test dependencies inside container
57
+ run: docker exec my-app-container pip install pytest pytest-cov
58
+
59
+ - name: Run integration tests with coverage
60
+ run: |
61
+ docker exec -w /app my-app-container python -m pytest app/tests/integration/tests_draft.py -v --cov=app --cov-report=xml --cov-report=html
62
+
63
+ - name: Copy coverage reports from container
64
+ run: |
65
+ docker cp my-app-container:/app/coverage.xml .
66
+ docker cp my-app-container:/app/htmlcov .
67
+
68
+ - name: Upload coverage report
69
+ uses: actions/upload-artifact@v4
70
+ with:
71
+ name: integration-coverage-report
72
+ path: |
73
+ coverage.xml
74
+ htmlcov/
75
+
76
+ - name: Remove .env file
77
+ run: rm .env
78
+
79
+ - name: Push to HF2 if tests pass
80
+ if: success()
81
+ env:
82
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
83
+ run: |
84
+ git lfs install
85
+ git lfs track "*.jpeg" "*.jpg" "*.png" "*.gif" "*.pdf" "*.zip" "*.bin"
86
+ git add .gitattributes
87
+ git add .
88
+ git commit -m "Add LFS tracking for binary files" || true
89
+ git checkout -b hf2-single-commit
90
+ git reset --soft $(git rev-list --max-parents=0 HEAD)
91
+ git commit -m "Single commit for HF2"
92
+ git remote add hf2 https://Andrchest:$HF_TOKEN@huggingface.co/spaces/The-Ultimate-RAG-HF/The-Ultimate-RAG
93
+ git push --force hf2 hf2-single-commit:main
94
+
95
+ - name: Stop and remove Docker container
96
+ if: always()
97
+ run: |
98
+ docker stop my-app-container
99
+ docker rm my-app-container
.github/workflows/unit-tests.yml ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Unit Tests
2
+ on:
3
+ pull_request:
4
+ branches:
5
+ - main
6
+ jobs:
7
+ test:
8
+ runs-on: ubuntu-latest
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+ - name: Set up Python
12
+ uses: actions/setup-python@v4
13
+ with:
14
+ python-version: '3.12'
15
+ - name: Install dependencies
16
+ run: |
17
+ python -m pip install --upgrade pip
18
+ pip install coverage
19
+ pip install -r app/requirements.txt
20
+ pip install flake8 pytest bandit
21
+ - name: Run linter
22
+ run: |
23
+ flake8 app/ --max-line-length=160 --extend-ignore=E203
24
+ - name: Run bandit
25
+ run: |
26
+ bandit -r app -x tests
27
+ - name: Run unit tests with coverage
28
+ env:
29
+ HF1_URL: ${{ secrets.HF1_URL }}
30
+ DATABASE_URL: ${{ secrets.DATABASE_URL }}
31
+ GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
32
+ SECRET_PEPPER: ${{ secrets.SECRET_PEPPER }}
33
+ JWT_ALGORITHM: ${{ secrets.JWT_ALGORITHM }}
34
+ PYTHONPATH: ${{ github.workspace }}
35
+ working-directory: ./
36
+ run: |
37
+ coverage run --source=app -m pytest app/tests/unit/test.py
38
+ coverage xml
39
+ coverage html
40
+ - name: Upload coverage report
41
+ uses: actions/upload-artifact@v4
42
+ with:
43
+ name: unit-coverage-report
44
+ path: |
45
+ coverage.xml
46
+ htmlcov/
47
+ - name: Upload coverage reports to Codecov
48
+ uses: codecov/codecov-action@v5
49
+ with:
50
+ token: ${{ secrets.CODECOV_TOKEN }}
51
+ files: ./coverage.xml
.gitignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__
2
+ /app/temp_storage
3
+ /database
4
+ /new_env
5
+ /prompt.txt
6
+ /app/key.py
7
+ /app/env_vars.py
8
+ /chats_storage
9
+ /.env
.vscode/settings.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "cSpell.words": [
3
+ "nessacary"
4
+ ]
5
+ }
CHANGELOG.md ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [MVPv2.5] - current version
6
+
7
+ - Updated README with deployment view
8
+ - Added ID for artificial user in tests
9
+ - Added more integration tests and proper pytest configuration
10
+ - Accelerated file processing
11
+
12
+
13
+ ## [MVPv2] - up to 2025-07-07
14
+
15
+ - Significantly reduced response time
16
+ - Enhanced response quality and accuracy
17
+ - Enhanced UI/UX
18
+ - Enhanced security: confidential information now stored in .env, single .py file
19
+ - Implemented unit and integration tests
20
+ - Introduced CI/CD pipeline
21
+ - Implemented response streaming (The response provided to the user when generating chunks)
22
+ - The information now accompanies a link where it was found
23
+ - Added JSON, CSV, and MD file types support
24
+ - Added a draft version of user and chat separation
25
+ - Addressed message saving bug
26
+ - Corrected prompt to align with task requirements
27
+ - Updated README and documentation
28
+
29
+
30
+ ## [MVPv1] - up to 2025-06-23
31
+
32
+ - Established a ready-to-use RAG skeleton\
33
+ *(The core function allows you to attach files and ask questions about their contents. You will receive a response that includes the information, the file name, the page number, and the exact location where the information is found.)*
34
+ - Multilingual support was added
35
+ - Added TXT, DOC, DOCX, and PDF file types support
36
+ - Implemented API with a simple frontend
37
+ - Added user registration and draft authentication backend logic
38
+ - Improved README and added License
39
+
CONTRIBUTING.md ADDED
File without changes
Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # syntax=docker/dockerfile:1
2
+ FROM python:3.12.10
3
+
4
+ RUN useradd -m -u 1000 user
5
+ USER user
6
+ ENV PATH="/home/user/.local/bin:$PATH"
7
+
8
+ WORKDIR /app
9
+
10
+ # copy and install Python reqs
11
+ COPY app/requirements.txt /app/requirements.txt
12
+ RUN pip install --no-cache-dir -r /app/requirements.txt
13
+
14
+ # download Qdrant binary
15
+ RUN wget https://github.com/qdrant/qdrant/releases/download/v1.11.5/qdrant-x86_64-unknown-linux-gnu.tar.gz \
16
+ && tar -xzf qdrant-x86_64-unknown-linux-gnu.tar.gz \
17
+ && mv qdrant /home/user/.local/bin/qdrant \
18
+ && rm qdrant-x86_64-unknown-linux-gnu.tar.gz
19
+
20
+ COPY --chown=user . /app
21
+
22
+ RUN chmod +x start.sh
23
+
24
+ CMD ["./start.sh"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Danil Popov
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.
README.md CHANGED
@@ -1,2 +1,337 @@
 
 
 
 
 
 
 
 
 
 
1
  # The-Ultimate-RAG
2
- [S25] Software project for Innopolis University
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: The Ultimate RAG
3
+ emoji: 🌍
4
+ colorFrom: pink
5
+ colorTo: indigo
6
+ sdk: docker
7
+ pinned: false
8
+ short_description: the ultimate rag
9
+ ---
10
+
11
  # The-Ultimate-RAG
12
+
13
+
14
+
15
+ ## Overview
16
+
17
+ ![logo](logo.svg)
18
+
19
+ [S25] The Ultimate RAG is an Innopolis University software project that generates cited responses from a local database.
20
+
21
+ ## Prerequisites
22
+
23
+ Before you begin, ensure the following is installed on your machine:
24
+
25
+ - [Python](https://www.python.org/)
26
+ - [Docker](https://www.docker.com/get-started/)
27
+
28
+ ## Installation
29
+
30
+ 1. **Clone the repository**
31
+ ```bash
32
+ git clone https://github.com/PopovDanil/The-Ultimate-RAG
33
+ cd The-Ultimate-RAG
34
+ ```
35
+ 2. **Set up a virtual environment (recommended)**
36
+
37
+ To isolate project dependencies and avoid conflicts, create a virtual environment:
38
+ - **On Unix/Linux/macOS:**
39
+ ```bash
40
+ python3 -m venv env
41
+ source env/bin/activate
42
+ ```
43
+ - **On Windows:**
44
+ ```bash
45
+ python -m venv env
46
+ env\Scripts\activate
47
+ ```
48
+ 3. **Install required libraries**
49
+
50
+ Within the activated virtual environment, install the dependencies:
51
+ ```bash
52
+ pip install -r ./app/requirements.txt
53
+ ```
54
+ *Note:* ensure you are in the virtual environment before running the command
55
+
56
+ 4. **Set up Docker**
57
+ - Ensure Docker is running on your machine
58
+ - Open a terminal, navigate to project directory, and run:
59
+ ```bash
60
+ docker-compose up --build
61
+ ```
62
+ *Note:* The initial build may take 10–20 minutes, as it needs to download large language models and other
63
+ dependencies.
64
+ Later launches will be much faster.
65
+
66
+ 5. **Server access**
67
+
68
+ Once the containers are running, visit `http://localhost:5050`. You should see the application’s welcome page
69
+
70
+ To stop the application and shut down all containers, press `Ctrl+C` in the terminal where `docker-compose` is running,
71
+ and then run:
72
+
73
+ ```bash
74
+ docker-compose down
75
+ ```
76
+
77
+ ## Usage
78
+
79
+ You can try currently deployed version of the system [here](https://huggingface.co/spaces/The-Ultimate-RAG-HF/The-Ultimate-RAG). **Note**: you should use the following instructions:
80
+ - Access the cite, you should see the *main* page with the name of the system
81
+ - Press the button "+ Add new chat", wait until the *login* page is loaded
82
+ - Find button "Register" (for now it is highly recommended to follow the instructions *strictly*) and press it
83
+ - You should be redirected to *sing up* page, here you should enter your credentials (you can use Test1@test1.com in all field for testing)
84
+ - Click **ONLY ONCE** on the button "Sign Up", and wait (for now it takes around 10 seconds to load *chat* page)
85
+ - Now you will be able to communicate with the system
86
+ - You can try to ask any thing and attach files. Enter a query and press the *enter* button (near the input area)
87
+
88
+ ## Architecture
89
+
90
+ ### Static view
91
+
92
+ The following **diagram** depicts the current state of our codebase organization.
93
+ ![Diagram description](./docs/architecture/static-view/static-view.png)
94
+
95
+ We have decided to adapt this architecture to enhance the *maintainability* of the product for the following reasons:
96
+ - [x] A **modular** system, which is reflected by the use of subsystems in our code increases the **reusability** of the components.
97
+ - [x] Individual subsystems can be easily **analyzed** in conjunction with monolith products.
98
+ - [x] This approach ensures the ease and speed of **testing**.
99
+ - [x] Additionally, each part can be **easily modified** without affecting the rest of the codebase.
100
+
101
+ ### Dynamic view
102
+
103
+ The following **diagram** depicts the one non-trivial case of the system use: user queries the system and attach file. This diagram can halp in understating the pipeline of file processing and response generation:
104
+ ![Diagram description](./docs/architecture/dynamic-view/dynamic-view.jpeg)
105
+
106
+ ### Deployment view
107
+
108
+ The deployment architecture of The Ultimate RAG is designed to ensure reliable, scalable, and isolated environments for testing and production.
109
+
110
+ ```mermaid
111
+ graph TD
112
+ subgraph Test Environment
113
+ TEST_SPACE[Hugging Face Space <br> Test Server] -->|Connects to| TEST_DB[Test PostgreSQL Database]
114
+ TEST_SPACE -->|Runs| APP_TEST[Docker Container: Application]
115
+ end
116
+
117
+ subgraph Production Environment
118
+ PROD_SPACE[Hugging Face Space <br> Production Server] -->|Connects to| PROD_DB[Production PostgreSQL Database]
119
+ PROD_SPACE -->|Runs| APP_PROD[Docker Container: Application]
120
+ end
121
+
122
+ subgraph CI/CD Pipeline
123
+ GITHUB[GitHub Repository] -->|Push to| TEST_SPACE
124
+ TEST_SPACE -->|Integration Tests Pass| PROD_SPACE
125
+ end
126
+
127
+ subgraph External Services
128
+ TEST_DB -->|Hosted on| DB_SERVICE[Supabase]
129
+ PROD_DB -->|Hosted on| DB_SERVICE
130
+ end
131
+
132
+ classDef server fill:#f9f,stroke:#333,stroke-width:2px,color:#000000;
133
+ classDef db fill:#bbf,stroke:#333,stroke-width:2px,color:#000000;
134
+ classDef pipeline fill:#bfb,stroke:#333,stroke-width:2px,color:#000000;
135
+ class TEST_SPACE,PROD_SPACE,APP_TEST,APP_PROD server;
136
+ class TEST_DB,PROD_DB db;
137
+ class GITHUB pipeline;
138
+ ```
139
+
140
+ - **Diagram Location:** The deployment diagram is stored at [`docs/architecture/deployment-view/deployment.mmd`](/docs/architecture/deployment-view/deployment.md).
141
+
142
+ **Deployment Choices:**
143
+ - **Hugging Face Spaces:** We use Hugging Face Spaces for both test and production environments due to their ease of use, free tier, and seamless integration with Git-based deployment. This allows rapid deployment and automatic scaling for our Python application.
144
+ - **Docker:** The application is containerized using Docker (defined in `docker-compose.yml`) to ensure consistency across test and production environments, simplifying dependency management and deployment.
145
+ - **Separate PostgreSQL Service:** The test and production PostgreSQL databases are hosted on an external service (not Hugging Face) to provide scalability, isolation, and robust database management. This ensures that test data does not interfere with production data.
146
+ - **Isolation of Environments:** Separate Hugging Face Spaces and databases for test and production prevent test activities from affecting the live application, ensuring stability for end users.
147
+
148
+ **Customer Deployment:**
149
+ Customers can access the application directly via the production Hugging Face Space at [URL to be provided]. No local deployment is required, as the application is hosted and managed on Hugging Face. To interact with the application, customers need:
150
+ - A web browser to access the production URL.
151
+ - Optional: API keys or credentials (contact the [DevOps lead](https://github.com/Andrchest) for access details, if applicable).
152
+ If customers prefer to deploy the application locally, they can follow the [Installation](#installation) instructions in this README, which include cloning the repository, setting up Docker, and configuring a `.env` file with a PostgreSQL connection string (contact the [DevOps lead](https://github.com/Andrchest) for details).
153
+
154
+ ## Development
155
+
156
+ ### Kanban board
157
+ **Link to the board**: [Kanban board](https://github.com/orgs/The-Ultimate-RAG/projects/3/views/2)
158
+
159
+ #### Column Entry Criteria
160
+
161
+ ##### 1. **To Do**
162
+ - [x] Issue is created using the project’s issue templates.
163
+ - [x] Issue is **estimated** (story points) by the Team.
164
+ - [x] Issue is **prioritized** by the Team.
165
+ - [x] Issue is **assigned**.
166
+
167
+ ##### 2. **In Progress**
168
+ - [x] **Merge Request (MR)** is created and linked to the issue.
169
+ - [x] **Reviewer(s)** are assigned.
170
+ - [x] Code passes **automated checks** (unit&integration testing, linting).
171
+
172
+ ##### 4. **Ready to Deploy**
173
+ - [x] MR is **approved** by at least one reviewer.
174
+ - [x] All **review comments** are resolved.
175
+ - [x] Code is **merged** into target branch (`main`).
176
+
177
+ ##### 5. **User Testing** *(Optional)*
178
+ - [x] Feature is deployed to **testing** server.
179
+ - [x] Testers/stakeholders are **notified**
180
+
181
+ #### 6. **Done**
182
+ - [x] Feature is deployed to **production** server.
183
+ - [x] User testing (if needed) is **approved**.
184
+ - [x] Issue is **closed**.
185
+
186
+ ### Git Workflow
187
+
188
+ #### Base Workflow
189
+ We have developed our **custom** workflow due to CI/CD integration issues and features of the development process. Key principles:
190
+ - `main` is always deployable.
191
+ - Feature branches are created from `main` and merged back via Pull Requests (PRs).
192
+ - No long-lived branches except `for_testing`, which serves for deploy to the testing server.
193
+
194
+ ---
195
+
196
+ #### Rules
197
+
198
+ ##### **1. Issues**
199
+ - Use the one of the Issue Templates.
200
+ - Include: **Description**, **Labels**, and **Milestone**.
201
+ - Assign the most logically suitable *label* from the list of [labels](https://github.com/The-Ultimate-RAG/The-Ultimate-RAG/issues/labels) (read their description first).
202
+ - Assign the issue to yourself, and contact [PM](https://github.com/PopovDanil) to re-assign if needed.
203
+
204
+ ##### **2. Branching**
205
+ - For developing new feature create a new branch.
206
+ - There are now strict rules for naming, but each name should logically depict the changes on code (e.g. add response streaming &rarr; response_stream).
207
+ - For each merge mention the reason why branches were merged.
208
+
209
+ ##### **3. Commit Messages**
210
+ - Template: `<type>(<scope>): <description>`.
211
+ - Examples:
212
+ ```
213
+ feat(auth): add login button
214
+ fix(api): resolve null pointer in user endpoint
215
+ ```
216
+
217
+ ##### **4. Pull Requests (PRs) and Reviews**
218
+ - Use the [PR Template](/.github/PULL_REQUEST_TEMPLATE/standart.md).
219
+ - Target branch - `main`, but for testing `for_testing` can be used.
220
+ - Contact [PM](https://github.com/PopovDanil) to assign Reviewers.
221
+ - Merge pull request if the code passes **review**, **tests** and **linter** (in other case you will be unable to do it).
222
+ - Delete branch after merge.
223
+
224
+ ##### **5. Resolving Issues**
225
+ - Close manually only after:
226
+ - [x] PR is merged.
227
+ - [x] Feature is verified in production (if applicable).
228
+
229
+
230
+ #### **Basic workflow example**
231
+ ```mermaid
232
+ flowchart LR
233
+ A[Create Issue] --> B[Create Branch]
234
+ B --> C[Commit & Push]
235
+ C --> D[Open PR]
236
+ D --> E{Code Review}
237
+ E -->|Approved| F[Squash Merge]
238
+ E -->|Rejected| C
239
+ F --> G[Verify in Prod]
240
+ G --> H[Close Issue]
241
+
242
+ %% Detailed Annotations
243
+ subgraph "Issue Creation"
244
+ A
245
+ end
246
+
247
+ subgraph "Development"
248
+ B
249
+ C
250
+ end
251
+
252
+ subgraph "Collaboration"
253
+ D
254
+ E
255
+ end
256
+
257
+ subgraph "Release"
258
+ F
259
+ G
260
+ H
261
+ end
262
+ ```
263
+
264
+ ---
265
+
266
+ ### Secrets management
267
+ Contact [DevOps lead](https://github.com/Andrchest) for more information.
268
+ All the secrets are stored in `.env` file. Its content will be provided after request to DevOps lead.
269
+
270
+ ## Quality assurance
271
+
272
+ ### Quality attribute scenarios
273
+ You can find scenarios in the [docs/quality-assurance/quality-attribute-scenarios.md](./docs/quality-assurance/quality-attribute-scenarios.md)
274
+
275
+ ### Automated tests
276
+ We've implemented a comprehensive automated testing suite using the following tools:
277
+ - 🐍 [pytest](https://docs.pytest.org/) - Primary test runner and framework
278
+ - ⚡ [httpx](https://www.python-httpx.org/) - Async HTTP client for API testing
279
+
280
+ | Test Type | Location | Description | Tools Used |
281
+ |--------------------|-------------------------------|-----------------------------------------------------------------------------|---------------------|
282
+ | Unit Tests | `app/tests/unit/` | Tests for individual components and utility functions | pytest |
283
+ | Integration Tests | `app/tests/integration/` | Tests for component interactions and, API and RAG systems integrations | pytest + httpx |
284
+ | Performance Tests | `app/tests/performance/` | *Will be added soon.* Will collect the statistical information of time, speed, and correctness evaluations | pytest + httpx |
285
+
286
+
287
+ ### User acceptance tests
288
+ See [acceptance test](./docs/quality-assurance/user-acceptance-tests.md) for the formal definition of system readiness.
289
+
290
+ ## Build and deployment
291
+
292
+ ### Continuous Integration
293
+
294
+ Our Continuous Integration (CI) pipeline ensures code quality by running automated checks on every pull request to the `main` branch. The pipeline is managed using GitHub Actions and is defined in:
295
+
296
+ - [`.github/workflows/unit-tests.yml`](https://github.com/The-Ultimate-RAG/The-Ultimate-RAG/blob/main/.github/workflows/unit-tests.yml)
297
+
298
+ In the CI pipeline, we use the following tools:
299
+
300
+ - **Static Analysis Tools:**
301
+ - **flake8**: A linter for Python that enforces coding style and detects programming errors.
302
+ - **bandit**: A security vulnerability scanner for Python, identifying potential security issues in the codebase.
303
+
304
+ - **Testing Tools:**
305
+ - **pytest**: A testing framework for Python, used to run unit tests located in [`app/tests/unit`](https://github.com/The-Ultimate-RAG/The-Ultimate-RAG/tree/main/app/tests/unit).
306
+
307
+ If any checks fail, the pull request cannot be merged into `main`. All CI workflow runs can be viewed at:
308
+
309
+ - [GitHub Actions - CI Workflow Runs](https://github.com/The-Ultimate-RAG/The-Ultimate-RAG/actions/workflows/unit-tests.yml)
310
+
311
+ ### Continuous Deployment
312
+
313
+ Our Continuous Deployment (CD) pipeline automatically deploys the application after a successful merge into `main`. The pipeline is defined in:
314
+
315
+ - [`.github/workflows/sync-to-hf.yml`](https://github.com/The-Ultimate-RAG/The-Ultimate-RAG/blob/main/.github/workflows/sync-to-hf.yml)
316
+
317
+ The CD pipeline performs the following steps:
318
+ 1. Pushes the updated code to a **Hugging Face Space** (test environment) using `git`, where it is automatically deployed.
319
+ 2. Runs **integration tests** on the test server with a test PostgreSQL database (hosted on a separate service), using tests located in [`app/tests/integration`](https://github.com/The-Ultimate-RAG/The-Ultimate-RAG/tree/main/app/tests/integration).
320
+ 3. If integration tests pass, deploys to a separate **Hugging Face Space** (production environment) with a production PostgreSQL database (also hosted on a separate service). Deployment takes approximately 2–3 minutes.
321
+ 4. If any tests fail, the production server remains unaffected.
322
+
323
+ In the CD pipeline, we use the following tools:
324
+
325
+ - **Deployment Tools:**
326
+ - **Docker**: Builds and packages the application as a container.
327
+ - **git**: Pushes the application to Hugging Face Spaces for deployment.
328
+ - **Testing Tools:**
329
+ - **pytest**: Runs integration tests on the test server.
330
+
331
+ All CD workflow runs can be viewed at:
332
+
333
+ - [GitHub Actions - CD Workflow Runs](https://github.com/The-Ultimate-RAG/The-Ultimate-RAG/actions/workflows/sync-to-hf.yml)
334
+
335
+ ## License
336
+
337
+ This project is licensed under the [MIT License](LICENSE).
app/__init__.py ADDED
File without changes
app/api/__init__.py ADDED
File without changes
app/api/api.py ADDED
@@ -0,0 +1,353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import (
2
+ FastAPI,
3
+ UploadFile,
4
+ Form,
5
+ File,
6
+ HTTPException,
7
+ Response,
8
+ Request,
9
+ Depends,
10
+ )
11
+ from fastapi.responses import (
12
+ FileResponse,
13
+ RedirectResponse,
14
+ StreamingResponse,
15
+ JSONResponse,
16
+ )
17
+ from fastapi.templating import Jinja2Templates
18
+ from fastapi.staticfiles import StaticFiles
19
+ from pydantic import BaseModel
20
+
21
+ from app.backend.controllers.users import (
22
+ create_user,
23
+ authenticate_user,
24
+ check_cookie,
25
+ clear_cookie,
26
+ get_current_user,
27
+ get_latest_chat,
28
+ )
29
+ from app.backend.controllers.chats import (
30
+ create_new_chat,
31
+ get_chat_with_messages,
32
+ update_title,
33
+ )
34
+ from app.backend.controllers.messages import register_message
35
+ from app.backend.schemas import SUser
36
+ from app.backend.models.users import User
37
+
38
+ from app.core.utils import (
39
+ TextHandler,
40
+ PDFHandler,
41
+ protect_chat,
42
+ extend_context,
43
+ initialize_rag,
44
+ save_documents,
45
+ construct_collection_name,
46
+ create_collection,
47
+ )
48
+ from app.settings import BASE_DIR, url_user_not_required
49
+ from app.core.document_validator import path_is_valid
50
+ from app.core.response_parser import add_links
51
+ from typing import Optional
52
+ import os
53
+
54
+ # TODO: implement a better TextHandler
55
+ # TODO: optionally implement DocHandler
56
+
57
+ api = FastAPI()
58
+
59
+ api.mount(
60
+ "/chats_storage",
61
+ StaticFiles(directory=os.path.join(BASE_DIR, "chats_storage")),
62
+ name="chats_storage",
63
+ )
64
+ api.mount(
65
+ "/static",
66
+ StaticFiles(directory=os.path.join(BASE_DIR, "app", "frontend", "static")),
67
+ name="static",
68
+ )
69
+ templates = Jinja2Templates(
70
+ directory=os.path.join(BASE_DIR, "app", "frontend", "templates")
71
+ )
72
+ rag = initialize_rag()
73
+
74
+ # NOTE: carefully read documentation to require_user
75
+ # <--------------------------------- Middleware --------------------------------->
76
+ """
77
+ Special class to have an opportunity to redirect user to login page in middleware
78
+ """
79
+
80
+
81
+ class AwaitableResponse:
82
+ def __init__(self, response: Response):
83
+ self.response = response
84
+
85
+ def __await__(self):
86
+ yield
87
+ return self.response
88
+
89
+
90
+ """
91
+ TODO: remove KOSTYLY -> find better way to skip requesting to login while showing pdf
92
+
93
+ Middleware that requires user to log in into the system before accessing any utl
94
+
95
+ NOTE: For now it is applied to all routes, but if you want to skip any, add it to the
96
+ url_user_not_required list in settings.py (/ should be removed)
97
+ """
98
+
99
+
100
+ @api.middleware("http")
101
+ async def require_user(request: Request, call_next):
102
+ print(request.url.path, request.method, request.url.port)
103
+
104
+ awaitable_response = AwaitableResponse(RedirectResponse("/login", status_code=303))
105
+ stripped_path = request.url.path.strip("/")
106
+
107
+ if (
108
+ stripped_path in url_user_not_required
109
+ or stripped_path.startswith("pdfs")
110
+ or "static/styles.css" in stripped_path
111
+ or "favicon.ico" in stripped_path
112
+ ):
113
+ return await call_next(request)
114
+
115
+ user = get_current_user(request)
116
+ if user is None:
117
+ return await awaitable_response
118
+
119
+ response = await call_next(request)
120
+ return response
121
+
122
+
123
+ # <--------------------------------- Common routes --------------------------------->
124
+ @api.get("/health")
125
+ async def health_check():
126
+ return {"status": "ok"}
127
+
128
+
129
+ @api.get("/")
130
+ def root(request: Request):
131
+ current_template = "pages/main.html"
132
+ return templates.TemplateResponse(
133
+ current_template, extend_context({"request": request})
134
+ )
135
+
136
+
137
+ @api.post("/message_with_docs")
138
+ async def send_message(
139
+ request: Request,
140
+ files: list[UploadFile] = File(None),
141
+ prompt: str = Form(...),
142
+ chat_id=Form(None),
143
+ user: User = Depends(get_current_user),
144
+ ) -> StreamingResponse:
145
+ # response = ""
146
+ status = 200
147
+ try:
148
+ collection_name = construct_collection_name(user, chat_id)
149
+
150
+ register_message(content=prompt, sender="user", chat_id=int(chat_id))
151
+
152
+ await save_documents(
153
+ collection_name, files=files, RAG=rag, user=user, chat_id=chat_id
154
+ )
155
+
156
+ # response = rag.generate_response_stream(collection_name=collection_name, user_prompt=prompt, stream=True)
157
+ # async def stream_response():
158
+ # async for chunk in response:
159
+ # yield chunk.json()
160
+
161
+ return StreamingResponse(
162
+ rag.generate_response_stream(
163
+ collection_name=collection_name, user_prompt=prompt, stream=True
164
+ ),
165
+ status,
166
+ media_type="text/event-stream",
167
+ )
168
+ except Exception as e:
169
+ status = 500
170
+ print(e)
171
+
172
+
173
+ @api.post("/replace_message")
174
+ async def replace_message(request: Request):
175
+ data = await request.json()
176
+ updated_message = add_links(data.get("message", ""))
177
+ register_message(
178
+ content=updated_message, sender="assistant", chat_id=int(data.get("chat_id", 0))
179
+ )
180
+ return JSONResponse({"updated_message": updated_message})
181
+
182
+
183
+ @api.get("/viewer")
184
+ def show_document(
185
+ request: Request,
186
+ path: str,
187
+ page: Optional[int] = 1,
188
+ lines: Optional[str] = "1-1",
189
+ start: Optional[int] = 0,
190
+ ):
191
+ if not path_is_valid(path):
192
+ return HTTPException(status_code=404, detail="Document not found")
193
+
194
+ ext = path.split(".")[-1]
195
+ if ext == "pdf":
196
+ return PDFHandler(request, path=path, page=page, templates=templates)
197
+ elif ext in ("txt", "csv", "md", "json"):
198
+ return TextHandler(request, path=path, lines=lines, templates=templates)
199
+ elif ext in ("docx", "doc"):
200
+ return TextHandler(
201
+ request, path=path, lines=lines, templates=templates
202
+ ) # should be a bit different handler
203
+ else:
204
+ return FileResponse(path=path)
205
+
206
+
207
+ # <--------------------------------- Get --------------------------------->
208
+ @api.get("/new_user")
209
+ def new_user_post(request: Request):
210
+ current_template = "pages/registration.html"
211
+ return templates.TemplateResponse(
212
+ current_template, extend_context({"request": request})
213
+ )
214
+
215
+
216
+ @api.get("/login")
217
+ def login_get(request: Request):
218
+ current_template = "pages/login.html"
219
+ return templates.TemplateResponse(
220
+ current_template, extend_context({"request": request})
221
+ )
222
+
223
+
224
+ @api.get("/cookie_test")
225
+ def test_cookie(request: Request):
226
+ return check_cookie(request)
227
+
228
+
229
+ """
230
+ Use only for testing. For now, provides user info for logged ones, and redirects to
231
+ login in other case
232
+ """
233
+
234
+
235
+ @api.get("/test")
236
+ def test(request: Request, user: User = Depends(get_current_user)):
237
+ return {
238
+ "user": {
239
+ "email": user.email,
240
+ "password_hash": user.password_hash,
241
+ # "chats": user.chats, # Note: it will rise error since due to the optimization associated fields are not loaded
242
+ # it is just a reference, but the session is closed, however you are trying to get access to the data through this session
243
+ }
244
+ }
245
+
246
+
247
+ @api.post("/chats/id={chat_id}/history")
248
+ def show_chat_history(request: Request, chat_id: int):
249
+ chat = get_chat_with_messages(chat_id)
250
+ user = get_current_user(request)
251
+
252
+ update_title(chat["chat_id"])
253
+
254
+ if not protect_chat(user, chat_id):
255
+ raise HTTPException(401, "Yod do not have rights to use this chat!")
256
+
257
+ context = chat
258
+
259
+ return context
260
+
261
+
262
+ @api.get("/chats/id={chat_id}")
263
+ def show_chat(request: Request, chat_id: int):
264
+ current_template = "pages/chat.html"
265
+
266
+ chat = get_chat_with_messages(chat_id)
267
+ user = get_current_user(request)
268
+
269
+ update_title(chat["chat_id"])
270
+
271
+ if not protect_chat(user, chat_id):
272
+ raise HTTPException(401, "Yod do not have rights to use this chat!")
273
+
274
+ context = extend_context({"request": request, "user": user}, selected=chat_id)
275
+ context.update(chat)
276
+
277
+ return templates.TemplateResponse(current_template, context)
278
+
279
+
280
+ @api.get("/logout")
281
+ def logout(response: Response):
282
+ return clear_cookie(response)
283
+
284
+
285
+ @api.get("/last_user_chat")
286
+ def last_user_chat(request: Request, user: User = Depends(get_current_user)):
287
+ chat = get_latest_chat(user)
288
+ url = None
289
+
290
+ if chat is None:
291
+ print("new_chat")
292
+ new_chat = create_new_chat("new chat", user)
293
+ url = new_chat.get("url")
294
+
295
+ try:
296
+ create_collection(user, new_chat.get("chat_id"), rag)
297
+ except Exception as e:
298
+ raise HTTPException(500, e)
299
+
300
+ else:
301
+ url = f"/chats/id={chat.id}"
302
+
303
+ return RedirectResponse(url, status_code=303)
304
+
305
+
306
+ # <--------------------------------- Post --------------------------------->
307
+ @api.post("/new_user")
308
+ def new_user(response: Response, user: SUser):
309
+ return create_user(response, user.email, user.password)
310
+
311
+
312
+ class LoginData(BaseModel):
313
+ email: str
314
+ password: str
315
+
316
+
317
+ @api.post("/login")
318
+ def login_post(response: Response, user_data: LoginData):
319
+ try:
320
+ # Validate the user data against the SUser schema for regular users
321
+ # This enforces email format and password complexity for non-admins
322
+ user_schema = SUser(email=user_data.email, password=user_data.password)
323
+ except ValueError as e:
324
+ # If validation fails, return a detailed error
325
+ raise HTTPException(status_code=422, detail=f"Validation error: {e}")
326
+
327
+ # If validation passes, proceed with the standard authentication process
328
+ return authenticate_user(response, user_schema.email, user_schema.password)
329
+
330
+
331
+ @api.post("/new_chat")
332
+ def create_chat(
333
+ request: Request,
334
+ title: Optional[str] = "new chat",
335
+ user: User = Depends(get_current_user),
336
+ ):
337
+ new_chat = create_new_chat(title, user)
338
+ url = new_chat.get("url")
339
+ chat_id = new_chat.get("chat_id")
340
+
341
+ if url is None or chat_id is None:
342
+ raise HTTPException(500, "New chat was not created")
343
+
344
+ try:
345
+ create_collection(user, chat_id, rag)
346
+ except Exception as e:
347
+ raise HTTPException(500, e)
348
+
349
+ return RedirectResponse(url, status_code=303)
350
+
351
+
352
+ if __name__ == "__main__":
353
+ pass
app/automigration.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from app.backend.models.db_service import automigrate
2
+
3
+ if __name__ == "__main__":
4
+ automigrate()
app/backend/__init__.py ADDED
File without changes
app/backend/controllers/__init__.py ADDED
File without changes
app/backend/controllers/base_controller.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from sqlalchemy import create_engine
2
+ from app.settings import settings
3
+
4
+ postgres_config = settings.postgres.model_dump()
5
+ engine = create_engine(**postgres_config)
app/backend/controllers/chat_controller.py ADDED
File without changes
app/backend/controllers/chats.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.backend.models.users import User, get_user_chats
2
+ from app.backend.models.chats import (
3
+ new_chat,
4
+ get_chat_by_id,
5
+ get_chats_by_user_id,
6
+ refresh_title,
7
+ )
8
+ from app.backend.models.messages import get_messages_by_chat_id, Message
9
+ from app.settings import BASE_DIR
10
+ from fastapi import HTTPException
11
+ from datetime import datetime, timedelta
12
+ import os
13
+
14
+
15
+ def create_new_chat(title: str | None, user: User) -> dict:
16
+ chat_id = new_chat(title, user)
17
+ try:
18
+ path_to_chat = os.path.join(
19
+ BASE_DIR,
20
+ "chats_storage",
21
+ f"user_id={user.id}",
22
+ f"chat_id={chat_id}",
23
+ "documents",
24
+ )
25
+ os.makedirs(path_to_chat, exist_ok=True)
26
+ except Exception as e:
27
+ print(e)
28
+ raise HTTPException(500, "Unable to create a new chat")
29
+
30
+ return {"url": f"/chats/id={chat_id}", "chat_id": chat_id}
31
+
32
+
33
+ def dump_messages_dict(messages: list[Message], dst: dict) -> None:
34
+ history = []
35
+
36
+ for message in messages:
37
+ history.append({"role": message.sender, "content": message.content})
38
+ print(message.sender, message.content[:100])
39
+
40
+ dst.update({"history": history})
41
+
42
+
43
+ def get_chat_with_messages(id: int) -> dict:
44
+ response = {"chat_id": id}
45
+
46
+ chat = get_chat_by_id(id=id)
47
+ if chat is None:
48
+ raise HTTPException(418, f"Invalid chat id. Chat with id={id} does not exists!")
49
+
50
+ messages = get_messages_by_chat_id(id=id)
51
+ dump_messages_dict(messages, response)
52
+
53
+ return response
54
+
55
+
56
+ def create_dict_from_chat(chat) -> dict:
57
+ return {"id": chat.id, "title": chat.title}
58
+
59
+
60
+ def list_user_chats(user_id: int) -> list[dict]:
61
+ current_date = datetime.now()
62
+
63
+ today = []
64
+ last_week = []
65
+ last_month = []
66
+ other = []
67
+
68
+ chats = get_chats_by_user_id(user_id)
69
+ for chat in chats:
70
+ if current_date - timedelta(days=1) <= chat.created_at:
71
+ today.append(chat)
72
+ elif current_date - timedelta(weeks=1) <= chat.created_at:
73
+ last_week.append(chat)
74
+ elif current_date - timedelta(weeks=4) <= chat.created_at:
75
+ last_month.append(chat)
76
+ else:
77
+ other.append(chat)
78
+
79
+ result = []
80
+
81
+ # da da eto ochen ploho ...
82
+ if len(today):
83
+ result.append(
84
+ {"title": "TODAY", "chats": [create_dict_from_chat(chat) for chat in today]}
85
+ )
86
+ if len(last_week):
87
+ result.append(
88
+ {
89
+ "title": "LAST WEEK",
90
+ "chats": [create_dict_from_chat(chat) for chat in last_week],
91
+ }
92
+ )
93
+ if len(last_month):
94
+ result.append(
95
+ {
96
+ "title": "LAST MONTH",
97
+ "chats": [create_dict_from_chat(chat) for chat in last_month],
98
+ }
99
+ )
100
+ if len(other):
101
+ result.append(
102
+ {"title": "LATER", "chats": [create_dict_from_chat(chat) for chat in other]}
103
+ )
104
+
105
+ return result
106
+
107
+
108
+ def verify_ownership_rights(user: User, chat_id: int) -> bool:
109
+ return chat_id in [chat.id for chat in get_user_chats(user)]
110
+
111
+
112
+ def update_title(chat_id: int) -> bool:
113
+ return refresh_title(chat_id)
app/backend/controllers/messages.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+
3
+ from app.backend.models.messages import new_message
4
+
5
+
6
+ def remove_html_tags(content: str) -> str:
7
+ pattern = "<(.*?)>"
8
+ replace_with = (
9
+ "<a href=https://www.youtube.com/results?search_query=rickroll>click me</a>"
10
+ )
11
+ de_taggeed = re.sub(pattern, "REPLACE_WITH_RICKROLL", content)
12
+
13
+ return de_taggeed.replace("REPLACE_WITH_RICKROLL", replace_with)
14
+
15
+
16
+ def register_message(content: str, sender: str, chat_id: int) -> None:
17
+ message = content if sender == "assistant" else remove_html_tags(content)
18
+ return new_message(chat_id=chat_id, sender=sender, content=message)
app/backend/controllers/user_controller.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from datetime import datetime
3
+ from typing import Optional
4
+ from sqlalchemy.exc import SQLAlchemyError, IntegrityError
5
+ from sqlalchemy.orm import Session
6
+
7
+ from app.backend.schemas import LanguageOptions, ThemeOptions
8
+ from app.backend.exceptions import (
9
+ DatabaseError,
10
+ UserNotFoundError,
11
+ UserAlreadyExistsError,
12
+ )
13
+ from app.backend.models.users import User
14
+
15
+
16
+ class UserController:
17
+ def __init__(self, database_session: Session):
18
+ self.database = database_session
19
+
20
+ @staticmethod
21
+ def _execute_query(query) -> Optional[User]:
22
+ """
23
+ Helper method to execute a query and handle common database errors.
24
+ """
25
+ try:
26
+ return query.first()
27
+ except SQLAlchemyError as e:
28
+ logging.error(f"Database error during user query: {e}", exc_info=True)
29
+ raise DatabaseError(f"Failed to query user due to a database error: {e}")
30
+
31
+ def add_new_user(
32
+ self, email: str, password_hash: str, access_string_hash: str
33
+ ) -> User:
34
+ if self.find_user_by_email(email):
35
+ logging.warning(f"Attempted to register existing email: {email}")
36
+ raise UserAlreadyExistsError(f"User with email {email} already registered")
37
+
38
+ new_user = User(
39
+ email=email,
40
+ password_hash=password_hash,
41
+ access_string_hash=access_string_hash,
42
+ )
43
+
44
+ self.database.add(new_user)
45
+
46
+ try:
47
+ self.database.commit()
48
+ self.database.refresh(new_user)
49
+ logging.info(f"Successfully registered new user: {new_user}")
50
+ return new_user
51
+ except IntegrityError as e:
52
+ self.database.rollback()
53
+ logging.error(
54
+ f"Integrity error when adding user '{email}': {e}", exc_info=True
55
+ )
56
+ raise UserAlreadyExistsError(f"User with email {email} already exists")
57
+ except SQLAlchemyError as e:
58
+ self.database.rollback()
59
+ raise DatabaseError(f"Failed to add new user due to a database error: {e}")
60
+
61
+ def find_user_by_id(self, user_id: int) -> User | None:
62
+ query = self.database.query(User).filter(User.id == user_id)
63
+ return self._execute_query(query)
64
+
65
+ def find_user_by_email(self, email: str) -> User | None:
66
+ query = self.database.query(User).filter(User.email == email)
67
+ return self._execute_query(query)
68
+
69
+ def find_user_by_access_string(self, access_string_hash: str) -> User | None:
70
+ query = self.database.query(User).filter(
71
+ User.access_string_hash == access_string_hash
72
+ )
73
+ return self._execute_query(query)
74
+
75
+ def update_user(self, user_id: int, **kwargs) -> User:
76
+ user_to_update = self.find_user_by_id(user_id)
77
+
78
+ if not user_to_update:
79
+ raise UserNotFoundError("User not found")
80
+
81
+ allowed_updates = {
82
+ "language": LanguageOptions,
83
+ "theme": ThemeOptions,
84
+ "access_string_hash": str,
85
+ "password_hash": str,
86
+ "reset_token_expires_at": datetime,
87
+ }
88
+
89
+ for key, value in kwargs.items():
90
+ if key in allowed_updates:
91
+ expected_type = allowed_updates[key]
92
+ if not isinstance(value, expected_type) or value is None:
93
+ raise ValueError(
94
+ f"Invalid type for {key}. Expected {expected_type}, got {type(value).__name__}"
95
+ )
96
+
97
+ setattr(user_to_update, key, value)
98
+ else:
99
+ logging.warning(
100
+ f"Attempted to updated disallowed key: {key} for user {user_id}. Ignoring"
101
+ )
102
+
103
+ try:
104
+ self.database.commit()
105
+ self.database.refresh(user_to_update)
106
+ logging.info(f"Successfully updated user: {user_to_update}")
107
+ return user_to_update
108
+ except SQLAlchemyError as e:
109
+ logging.error(
110
+ f"Failed to update user ID {user_id} due to a database error: {e}",
111
+ exc_info=True,
112
+ )
113
+ raise DatabaseError(f"Failed to update user due to a database error: {e}")
app/backend/controllers/users.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.backend.models.users import (
2
+ User,
3
+ add_new_user,
4
+ find_user_by_email,
5
+ find_user_by_access_string,
6
+ update_user,
7
+ get_user_last_chat,
8
+ )
9
+ from app.backend.models.chats import Chat
10
+ from bcrypt import gensalt, hashpw, checkpw
11
+ from app.settings import settings
12
+ from fastapi import HTTPException
13
+ import jwt
14
+ from datetime import datetime, timedelta
15
+ from fastapi import Response, Request
16
+ from secrets import token_urlsafe
17
+ import hmac
18
+ import hashlib
19
+
20
+ # A vot nado bilo izuchat kak web dev rabotaet
21
+
22
+ """
23
+ Creates a jwt token by access string
24
+
25
+ Param:
26
+ access_string - randomly (safe methods) generated string (by default - 16 len)
27
+ expires_delta - time in seconds, defines a token lifetime
28
+
29
+ Returns:
30
+ string with 4 sections (valid jwt token)
31
+ """
32
+
33
+
34
+ def create_access_token(
35
+ access_string: str, expires_delta: timedelta = settings.max_cookie_lifetime
36
+ ) -> str:
37
+ token_payload = {
38
+ "access_string": access_string,
39
+ }
40
+
41
+ token_payload.update({"exp": datetime.now() + expires_delta})
42
+ encoded_jwt: str = jwt.encode(
43
+ token_payload, settings.secret_pepper, algorithm=settings.jwt_algorithm.replace("\r", "")
44
+ )
45
+
46
+ return encoded_jwt
47
+
48
+
49
+ """
50
+ Safely creates random string of 16 chars
51
+ """
52
+
53
+
54
+ def create_access_string() -> str:
55
+ return token_urlsafe(16)
56
+
57
+
58
+ """
59
+ Hashes access string using hmac and sha256
60
+
61
+ We can not use the same methods as we do to save password
62
+ since we need to know a salt to get similar hash, but since
63
+ we put a raw string (non-hashed) we won't be able to guess
64
+ salt
65
+ """
66
+
67
+
68
+ def hash_access_string(string: str) -> str:
69
+ return hmac.new(
70
+ key=str(settings.secret_pepper).encode("utf-8"),
71
+ msg=string.encode("utf-8"),
72
+ digestmod=hashlib.sha256,
73
+ ).hexdigest()
74
+
75
+
76
+ """
77
+ Creates a new user and sets a cookie with jwt token
78
+
79
+ Params:
80
+ response - needed to set a cookie
81
+ ...
82
+
83
+ Returns:
84
+ Dict to send a response in JSON
85
+ """
86
+
87
+
88
+ def create_user(response: Response, email: str, password: str) -> dict:
89
+ user: User = find_user_by_email(email=email)
90
+ if user is not None:
91
+ return HTTPException(418, "The user with similar email already exists")
92
+
93
+ salt: bytes = gensalt(rounds=16)
94
+ password_hashed: str = hashpw(password.encode("utf-8"), salt).decode("utf-8")
95
+
96
+ access_string: str = create_access_string()
97
+ access_string_hashed: str = hash_access_string(string=access_string)
98
+
99
+ id = add_new_user(
100
+ email=email,
101
+ password_hash=password_hashed,
102
+ access_string_hash=access_string_hashed,
103
+ )
104
+
105
+ print(id)
106
+
107
+ access_token: str = create_access_token(access_string=access_string)
108
+ response.set_cookie(
109
+ key="access_token",
110
+ value=access_token,
111
+ path="/",
112
+ max_age=settings.max_cookie_lifetime,
113
+ httponly=True,
114
+ )
115
+
116
+ return {"status": "ok", "id": id if id is not None else 0}
117
+
118
+
119
+ """
120
+ Finds user by email. If user is found, sets a cookie with token
121
+ """
122
+
123
+
124
+ def authenticate_user(response: Response, email: str, password: str) -> dict:
125
+ user: User = find_user_by_email(email=email)
126
+
127
+ if not user:
128
+ raise HTTPException(418, "User does not exists")
129
+
130
+ if not checkpw(password.encode("utf-8"), user.password_hash.encode("utf-8")):
131
+ raise HTTPException(418, "Wrong credentials")
132
+
133
+ access_string: str = create_access_string()
134
+ access_string_hashed: str = hash_access_string(string=access_string)
135
+
136
+ update_user(user, access_string_hash=access_string_hashed)
137
+
138
+ access_token = create_access_token(access_string)
139
+ response.set_cookie(
140
+ key="access_token",
141
+ value=access_token,
142
+ path="/",
143
+ max_age=settings.max_cookie_lifetime,
144
+ httponly=True,
145
+ )
146
+
147
+ return {"status": "ok"}
148
+
149
+
150
+ """
151
+ Get user from token stored in cookies
152
+ """
153
+
154
+
155
+ def get_current_user(request: Request) -> User | None:
156
+ user = None
157
+ token: str | None = request.cookies.get("access_token")
158
+ if not token:
159
+ return None
160
+
161
+ try:
162
+ access_string = jwt.decode(
163
+ jwt=bytes(token, encoding="utf-8"),
164
+ key=settings.secret_pepper,
165
+ algorithms=[settings.jwt_algorithm.replace("\r", "")],
166
+ ).get("access_string")
167
+
168
+ user = find_user_by_access_string(hash_access_string(access_string))
169
+ except Exception as e:
170
+ print(e)
171
+
172
+ if not user:
173
+ return None
174
+
175
+ return user
176
+
177
+
178
+ """
179
+ Checks if cookie with access token is present
180
+ """
181
+
182
+
183
+ def check_cookie(request: Request) -> dict:
184
+ result = {"token": "No token is present"}
185
+ token = request.cookies.get("access_token")
186
+ if token:
187
+ result["token"] = token
188
+ return result
189
+
190
+
191
+ def clear_cookie(response: Response) -> dict:
192
+ response.set_cookie(key="access_token", value="", httponly=True)
193
+ return {"status": "ok"}
194
+
195
+
196
+ def get_latest_chat(user: User) -> Chat | None:
197
+ return get_user_last_chat(user)
app/backend/models/__init__.py ADDED
File without changes
app/backend/models/base_model.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import Column, DateTime
2
+ from sqlalchemy.orm import DeclarativeBase
3
+ from sqlalchemy.sql import func
4
+
5
+
6
+ class Base(DeclarativeBase):
7
+ __abstract__ = True
8
+ created_at = Column("created_at", DateTime, default=func.now())
9
+ deleted_at = Column("deleted_at", DateTime, nullable=True)
10
+ updated_at = Column("updated_at", DateTime, nullable=True)
app/backend/models/chats.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.backend.models.base_model import Base
2
+ from sqlalchemy import Integer, String, Column, ForeignKey
3
+ from sqlalchemy.orm import relationship, Session
4
+ from app.backend.controllers.base_controller import engine
5
+
6
+
7
+ class Chat(Base):
8
+ __tablename__ = "chats"
9
+ id = Column("id", Integer, autoincrement=True, primary_key=True, unique=True)
10
+ title = Column("title", String, nullable=True)
11
+ user_id = Column(Integer, ForeignKey("users.id"))
12
+ user = relationship("User", back_populates="chats")
13
+ messages = relationship("Message", back_populates="chat")
14
+
15
+
16
+ def new_chat(title: str | None, user) -> int:
17
+ id = None
18
+ with Session(autoflush=False, bind=engine) as db:
19
+ user = db.merge(user)
20
+ new_chat = Chat(user_id=user.id, user=user)
21
+ if title:
22
+ new_chat.title = title
23
+ db.add(new_chat)
24
+ db.commit()
25
+ id = new_chat.id
26
+ return id
27
+
28
+
29
+ def get_chat_by_id(id: int) -> Chat | None:
30
+ with Session(autoflush=False, bind=engine) as db:
31
+ return db.query(Chat).where(Chat.id == id).first()
32
+
33
+
34
+ def get_chats_by_user_id(id: int) -> list[Chat]:
35
+ with Session(autoflush=False, bind=engine) as db:
36
+ return (
37
+ db.query(Chat).filter(Chat.user_id == id).order_by(Chat.created_at.desc())
38
+ )
39
+
40
+
41
+ def refresh_title(chat_id: int) -> bool:
42
+ with Session(autoflush=False, bind=engine) as db:
43
+ chat = db.get(Chat, chat_id)
44
+ messages = chat.messages
45
+
46
+ if messages is None or len(messages) == 0:
47
+ return False
48
+
49
+ chat.title = messages[0].content[:47]
50
+ if len(messages[0].content) > 46:
51
+ chat.title += "..."
52
+
53
+ db.commit()
54
+ return True
app/backend/models/db_service.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.backend.controllers.base_controller import engine
2
+ from app.backend.models.base_model import Base
3
+ from app.backend.models.chats import Chat
4
+ from app.backend.models.messages import Message
5
+ from app.backend.models.users import User
6
+
7
+
8
+ def table_exists(name: str) -> bool:
9
+ return engine.dialect.has_table(engine, name)
10
+
11
+
12
+ def create_tables() -> None:
13
+ Base.metadata.create_all(engine)
14
+
15
+
16
+ def drop_tables() -> None:
17
+ # for now the order matters, so
18
+ # TODO: add cascade deletion for models
19
+ Message.__table__.drop(engine)
20
+ Chat.__table__.drop(engine)
21
+ User.__table__.drop(engine)
22
+
23
+
24
+ def automigrate() -> None:
25
+ try:
26
+ drop_tables()
27
+ except Exception as e:
28
+ print(e)
29
+
30
+ create_tables()
app/backend/models/messages.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import Column, ForeignKey, Integer, String, Text
2
+ from sqlalchemy.orm import Session, relationship
3
+
4
+ from app.backend.controllers.base_controller import engine
5
+ from app.backend.models.base_model import Base
6
+
7
+
8
+ class Message(Base):
9
+ __tablename__ = "messages"
10
+ id = Column("id", Integer, autoincrement=True, primary_key=True, unique=True)
11
+ content = Column("text", Text)
12
+ sender = Column("role", String)
13
+ chat_id = Column(Integer, ForeignKey("chats.id"))
14
+ chat = relationship("Chat", back_populates="messages")
15
+
16
+
17
+ def new_message(chat_id: int, sender: str, content: str):
18
+ with Session(autoflush=False, bind=engine) as db:
19
+ db.add(Message(content=content, sender=sender, chat_id=chat_id))
20
+ db.commit()
21
+
22
+
23
+ def get_messages_by_chat_id(id: int) -> list[Message]:
24
+ with Session(autoflush=False, bind=engine) as db:
25
+ return db.query(Message).filter(Message.chat_id == id)
app/backend/models/users.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import Column, String, Integer
2
+ from sqlalchemy.orm import relationship, Session
3
+
4
+ from app.backend.models.base_model import Base
5
+ from app.backend.controllers.base_controller import engine
6
+ from app.backend.models.chats import Chat
7
+
8
+
9
+ class User(Base):
10
+ __tablename__ = "users"
11
+ id = Column("id", Integer, autoincrement=True, primary_key=True, unique=True)
12
+ email = Column("email", String, unique=True, nullable=False)
13
+ password_hash = Column("password_hash", String, nullable=False)
14
+ language = Column("language", String, default="English", nullable=False)
15
+ theme = Column("theme", String, default="light", nullable=False)
16
+ access_string_hash = Column("access_string_hash", String, nullable=True)
17
+ chats = relationship("Chat", back_populates="user")
18
+
19
+
20
+ def add_new_user(email: str, password_hash: str, access_string_hash: str) -> int | None:
21
+ with Session(autoflush=False, bind=engine, expire_on_commit=False) as db:
22
+ user = User(
23
+ email=email,
24
+ password_hash=password_hash,
25
+ access_string_hash=access_string_hash,
26
+ )
27
+ db.add(user)
28
+ db.commit()
29
+ db.refresh(user)
30
+ return user.id
31
+
32
+
33
+ def find_user_by_id(id: int) -> User | None:
34
+ with Session(autoflush=False, bind=engine) as db:
35
+ return db.query(User).where(User.id == id).first()
36
+
37
+
38
+ def find_user_by_email(email: str) -> User | None:
39
+ with Session(autoflush=False, bind=engine) as db:
40
+ return db.query(User).where(User.email == email).first()
41
+
42
+
43
+ def find_user_by_access_string(access_string_hash: str) -> User | None:
44
+ with Session(autoflush=False, bind=engine, expire_on_commit=False) as db:
45
+ user = (
46
+ db.query(User).where(User.access_string_hash == access_string_hash).first()
47
+ )
48
+ return user
49
+
50
+
51
+ def update_user(
52
+ user: User, language: str = None, theme: str = None, access_string_hash: str = None
53
+ ) -> None:
54
+ with Session(autoflush=False, bind=engine) as db:
55
+ user = db.merge(user)
56
+ if language:
57
+ user.language = language
58
+ if theme:
59
+ user.theme = theme
60
+ if access_string_hash:
61
+ user.access_string_hash = access_string_hash
62
+ db.commit()
63
+
64
+
65
+ def get_user_chats(user: User) -> list[Chat]:
66
+ with Session(autoflush=False, bind=engine) as db:
67
+ user = db.get(User, user.id)
68
+ return user.chats
69
+
70
+
71
+ def get_user_last_chat(user: User) -> Chat | None:
72
+ with Session(autoflush=False, bind=engine) as db:
73
+ user = db.get(User, user.id)
74
+
75
+ chats = user.chats
76
+
77
+ if chats is not None and len(chats):
78
+ return chats[-1]
79
+
80
+ return None
app/backend/schemas.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from enum import Enum
2
+ from pydantic import BaseModel, Field, EmailStr, field_validator
3
+ import re
4
+
5
+
6
+ class ThemeOptions(str, Enum):
7
+ LIGHT = "light"
8
+ DARK = "dark"
9
+
10
+
11
+ class LanguageOptions(str, Enum):
12
+ AR = "ar"
13
+ EN = "en"
14
+ RU = "ru"
15
+
16
+
17
+ class SUser(BaseModel):
18
+ email: EmailStr
19
+ password: str = Field(default=..., min_length=8, max_length=32)
20
+
21
+ @field_validator("password", mode="before")
22
+ def validate_password(cls, password_to_validate):
23
+ """
24
+ Validates the strength of the password.
25
+
26
+ The password **must** contain:
27
+ - At least one digit
28
+ - At least one special character
29
+ - At least one uppercase character
30
+ - At least one lowercase character
31
+ """
32
+
33
+ if not re.search(r"\d", password_to_validate):
34
+ raise ValueError("Password must contain at least one number.")
35
+
36
+ if not re.search(r"[!@#$%^&*()_+\-=\[\]{};:\'\",.<>?`~]", password_to_validate):
37
+ raise ValueError("Password must contain at least one special symbol.")
38
+
39
+ if not re.search(r"[A-Z]", password_to_validate):
40
+ raise ValueError("Password must contain at least one uppercase letter.")
41
+
42
+ if not re.search(r"[a-z]", password_to_validate):
43
+ raise ValueError("Password must contain at least one lowercase letter.")
44
+
45
+ return password_to_validate
app/core/__init__.py ADDED
File without changes
app/core/chunks.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+
3
+
4
+ class Chunk:
5
+ """
6
+ id -> unique number in uuid format, can be tried https://www.uuidgenerator.net/
7
+ start_index -> the index of the first char from the beginning of the original document
8
+
9
+ TODO: implement access modifiers and set of getters and setters
10
+ """
11
+
12
+ def __init__(
13
+ self,
14
+ id: uuid.UUID,
15
+ filename: str,
16
+ page_number: int,
17
+ start_index: int,
18
+ start_line: int,
19
+ end_line: int,
20
+ text: str,
21
+ ):
22
+ self.id: uuid.UUID = id
23
+ self.filename: str = filename
24
+ self.page_number: int = page_number
25
+ self.start_index: int = start_index
26
+ self.start_line: int = start_line
27
+ self.end_line: int = end_line
28
+ self.text: str = text
29
+
30
+ def get_raw_text(self) -> str:
31
+ return self.text
32
+
33
+ def get_splitted_text(self) -> list[str]:
34
+ return self.text.split(" ")
35
+
36
+ def get_metadata(self) -> dict:
37
+ return {
38
+ "id": self.id,
39
+ "filename": self.filename,
40
+ "page_number": self.page_number,
41
+ "start_index": self.start_index,
42
+ "start_line": self.start_line,
43
+ "end_line": self.end_line,
44
+ }
45
+
46
+ # TODO: remove kostyly
47
+ def __str__(self):
48
+ return (
49
+ f"Chunk from {self.filename.split('/')[-1]}, "
50
+ f"page - {self.page_number}, "
51
+ f"start - {self.start_line}, "
52
+ f"end - {self.end_line}, "
53
+ f"and text - {self.text[:100]}... ({len(self.text)})\n"
54
+ )
app/core/database.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from qdrant_client import QdrantClient # main component to provide the access to db
2
+ from qdrant_client.http.models import (
3
+ ScoredPoint,
4
+ Filter,
5
+ FieldCondition,
6
+ MatchText
7
+ )
8
+ from qdrant_client.models import (
9
+ VectorParams,
10
+ Distance,
11
+ PointStruct,
12
+ TextIndexParams,
13
+ TokenizerType
14
+ ) # VectorParams -> config of vectors that will be used as primary keys
15
+ from app.core.models import Embedder # Distance -> defines the metric
16
+ from app.core.chunks import Chunk # PointStruct -> instance that will be stored in db
17
+ import numpy as np
18
+ from uuid import UUID
19
+ from app.settings import settings
20
+ import time
21
+ from fastapi import HTTPException
22
+ import re
23
+
24
+
25
+ class VectorDatabase:
26
+ def __init__(self, embedder: Embedder, host: str = "qdrant", port: int = 6333):
27
+ self.host: str = host
28
+ self.client: QdrantClient = self._initialize_qdrant_client()
29
+ self.embedder: Embedder = embedder # embedder is used to convert a user's query
30
+ self.already_stored: np.array[np.array] = np.array([]).reshape(
31
+ 0, embedder.get_vector_dimensionality()
32
+ )
33
+
34
+ def store(
35
+ self, collection_name: str, chunks: list[Chunk], batch_size: int = 1000
36
+ ) -> None:
37
+ points: list[PointStruct] = []
38
+
39
+ vectors = self.embedder.encode([chunk.get_raw_text() for chunk in chunks])
40
+
41
+ for vector, chunk in zip(vectors, chunks):
42
+ if self.accept_vector(collection_name, vector):
43
+ points.append(
44
+ PointStruct(
45
+ id=str(chunk.id),
46
+ vector=vector,
47
+ payload={
48
+ "metadata": chunk.get_metadata(),
49
+ "text": chunk.get_raw_text(),
50
+ },
51
+ )
52
+ )
53
+
54
+ if len(points):
55
+ for group in range(0, len(points), batch_size):
56
+ self.client.upsert(
57
+ collection_name=collection_name,
58
+ points=points[group : group + batch_size],
59
+ wait=False,
60
+ )
61
+
62
+ """
63
+ Measures a cosine of angle between tow vectors
64
+ """
65
+
66
+ def cosine_similarity(self, vec1, vec2):
67
+ vec1_np = np.array(vec1)
68
+ vec2_np = np.array(vec2)
69
+ return vec1_np @ vec2_np / (np.linalg.norm(vec1_np) * np.linalg.norm(vec2_np))
70
+
71
+ """
72
+ Defines weather the vector should be stored in the db by searching for the most
73
+ similar one
74
+ """
75
+
76
+ def accept_vector(self, collection_name: str, vector: np.array) -> bool:
77
+ most_similar = self.client.query_points(
78
+ collection_name=collection_name, query=vector, limit=1, with_vectors=True
79
+ ).points
80
+
81
+ if not len(most_similar):
82
+ return True
83
+ else:
84
+ most_similar = most_similar[0]
85
+
86
+ if 1 - self.cosine_similarity(vector, most_similar.vector) < settings.max_delta:
87
+ return False
88
+ return True
89
+
90
+ def construct_keywords_list(self, query: str) -> list[FieldCondition]:
91
+ keywords = re.findall(r'\b[A-Z]{2,}\b', query)
92
+ filters = []
93
+
94
+ print(keywords)
95
+
96
+ for word in keywords:
97
+ if len(word) > 30 or len(word) < 2:
98
+ continue
99
+ filters.append(FieldCondition(key="text", match=MatchText(text=word)))
100
+
101
+ return filters
102
+
103
+ """
104
+ According to tests, re-ranker needs ~7-10 chunks to generate the most accurate hit
105
+
106
+ TODO: implement hybrid search
107
+ """
108
+
109
+ def search(self, collection_name: str, query: str, top_k: int = 5) -> list[Chunk]:
110
+ query_embedded: np.ndarray = self.embedder.encode(query)
111
+
112
+ if isinstance(query_embedded, list):
113
+ query_embedded = query_embedded[0]
114
+
115
+ keywords = self.construct_keywords_list(query)
116
+
117
+ dense_result: list[ScoredPoint] = self.client.query_points(
118
+ collection_name=collection_name, query=query_embedded, limit=int(top_k * 0.7)
119
+ ).points
120
+
121
+ sparse_result: list[ScoredPoint] = self.client.query_points(
122
+ collection_name=collection_name, query=query_embedded, limit=int(top_k * 0.3),
123
+ query_filter=Filter(should=keywords)
124
+ ).points
125
+
126
+ combined = [*dense_result, *sparse_result]
127
+
128
+ print(len(combined))
129
+
130
+ return [
131
+ Chunk(
132
+ id=UUID(point.payload.get("metadata", {}).get("id", "")),
133
+ filename=point.payload.get("metadata", {}).get("filename", ""),
134
+ page_number=point.payload.get("metadata", {}).get("page_number", 0),
135
+ start_index=point.payload.get("metadata", {}).get("start_index", 0),
136
+ start_line=point.payload.get("metadata", {}).get("start_line", 0),
137
+ end_line=point.payload.get("metadata", {}).get("end_line", 0),
138
+ text=point.payload.get("text", ""),
139
+ )
140
+ for point in combined
141
+ ]
142
+
143
+ def _initialize_qdrant_client(self, max_retries=5, delay=2) -> QdrantClient:
144
+ for attempt in range(max_retries):
145
+ try:
146
+ client = QdrantClient(**settings.qdrant.model_dump())
147
+ client.get_collections()
148
+ return client
149
+ except Exception as e:
150
+ if attempt == max_retries - 1:
151
+ raise HTTPException(
152
+ 500,
153
+ f"Failed to connect to Qdrant server after {max_retries} attempts. "
154
+ f"Last error: {str(e)}",
155
+ )
156
+
157
+ print(
158
+ f"Connection attempt {attempt + 1} out of {max_retries} failed. "
159
+ f"Retrying in {delay} seconds..."
160
+ )
161
+
162
+ time.sleep(delay)
163
+ delay *= 2
164
+
165
+ def _check_collection_exists(self, collection_name: str) -> bool:
166
+ try:
167
+ return self.client.collection_exists(collection_name)
168
+ except Exception as e:
169
+ raise HTTPException(
170
+ 500,
171
+ f"Failed to check collection {collection_name} exists. Last error: {str(e)}",
172
+ )
173
+
174
+ def _create_collection(self, collection_name: str) -> None:
175
+ try:
176
+ self.client.create_collection(
177
+ collection_name=collection_name,
178
+ vectors_config=VectorParams(
179
+ size=self.embedder.get_vector_dimensionality(),
180
+ distance=Distance.COSINE,
181
+ ),
182
+ )
183
+ self.client.create_payload_index(
184
+ collection_name=collection_name,
185
+ field_name="text",
186
+ field_schema=TextIndexParams(
187
+ type="text",
188
+ tokenizer=TokenizerType.WORD,
189
+ min_token_len=2,
190
+ max_token_len=30,
191
+ lowercase=True
192
+ )
193
+ )
194
+ except Exception as e:
195
+ raise HTTPException(
196
+ 500, f"Failed to create collection {self.collection_name}: {str(e)}"
197
+ )
198
+
199
+ def create_collection(self, collection_name: str) -> None:
200
+ try:
201
+ if self._check_collection_exists(collection_name):
202
+ return
203
+ self._create_collection(collection_name)
204
+ except Exception as e:
205
+ print(e)
206
+ raise HTTPException(500, e)
207
+
208
+ def __del__(self):
209
+ if hasattr(self, "client"):
210
+ self.client.close()
211
+
212
+ def get_collections(self) -> list[str]:
213
+ try:
214
+ return self.client.get_collections()
215
+ except Exception as e:
216
+ print(e)
217
+ raise HTTPException(500, "Failed to get collection names")
app/core/document_validator.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ """
4
+ Checks if the given path is valid and file exists
5
+ """
6
+
7
+
8
+ def path_is_valid(path: str) -> bool:
9
+ return os.path.exists(path)
app/core/main.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.settings import settings, BASE_DIR
2
+ import uvicorn
3
+ import os
4
+ from app.backend.models.db_service import automigrate
5
+
6
+
7
+ def initialize_system() -> bool:
8
+ path = BASE_DIR
9
+ chats_storage_path = os.path.join(path, "chats_storage")
10
+ database_path = os.path.join(path, "database")
11
+
12
+ try:
13
+ os.makedirs(database_path, exist_ok=True)
14
+ os.makedirs(chats_storage_path, exist_ok=True)
15
+ except Exception:
16
+ raise RuntimeError("Not all required directories were initialized")
17
+
18
+ try:
19
+ # os.system(f"pip install -r {os.path.join(base_path, 'requirements.txt')}")
20
+ pass
21
+ except Exception:
22
+ raise RuntimeError("Not all package were downloaded")
23
+
24
+
25
+ def main():
26
+ automigrate() # Note: it will drop all existing dbs and create a new ones
27
+ initialize_system()
28
+ uvicorn.run(**settings.api.model_dump())
29
+
30
+
31
+ if __name__ == "__main__":
32
+ # ATTENTION: run from base dir ---> python -m app.main
33
+ main()
app/core/models.py ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ from dotenv import load_dotenv
4
+ from sentence_transformers import (
5
+ SentenceTransformer,
6
+ CrossEncoder,
7
+ ) # SentenceTransformer -> model for embeddings, CrossEncoder -> re-ranker
8
+ from ctransformers import AutoModelForCausalLM
9
+ from torch import Tensor
10
+ from google import genai
11
+ from google.genai import types
12
+ from app.core.chunks import Chunk
13
+ from app.settings import settings, BASE_DIR, GeminiEmbeddingSettings
14
+
15
+ load_dotenv()
16
+
17
+
18
+ class Embedder:
19
+ def __init__(self, model: str = "BAAI/bge-m3"):
20
+ self.device: str = settings.device
21
+ self.model_name: str = model
22
+ self.model: SentenceTransformer = SentenceTransformer(model, device=self.device)
23
+
24
+ """
25
+ Encodes string to dense vector
26
+ """
27
+
28
+ def encode(self, text: str | list[str]) -> Tensor | list[Tensor]:
29
+ return self.model.encode(sentences=text, show_progress_bar=False, batch_size=32)
30
+
31
+ """
32
+ Returns the dimensionality of dense vector
33
+ """
34
+
35
+ def get_vector_dimensionality(self) -> int | None:
36
+ return self.model.get_sentence_embedding_dimension()
37
+
38
+
39
+ class Reranker:
40
+ def __init__(self, model: str = "cross-encoder/ms-marco-MiniLM-L6-v2"):
41
+ self.device: str = settings.device
42
+ self.model_name: str = model
43
+ self.model: CrossEncoder = CrossEncoder(model, device=self.device)
44
+
45
+ """
46
+ Returns re-sorted (by relevance) vector with dicts, from which we need only the 'corpus_id'
47
+ since it is a position of chunk in original list
48
+ """
49
+
50
+ def rank(self, query: str, chunks: list[Chunk]) -> list[dict[str, int]]:
51
+ return self.model.rank(query, [chunk.get_raw_text() for chunk in chunks])
52
+
53
+
54
+ # TODO: add models parameters to global config file
55
+ # TODO: add exception handling when response have more tokens than was set
56
+ # TODO: find a way to restrict the model for providing too long answers
57
+
58
+
59
+ class LocalLLM:
60
+ def __init__(self):
61
+ self.model = AutoModelForCausalLM.from_pretrained(
62
+ **settings.local_llm.model_dump()
63
+ )
64
+
65
+ """
66
+ Produces the response to user's prompt
67
+
68
+ stream -> flag, determines weather we need to wait until the response is ready or can show it token by token
69
+
70
+ TODO: invent a way to really stream the answer (as return value)
71
+ """
72
+
73
+ def get_response(
74
+ self,
75
+ prompt: str,
76
+ stream: bool = True,
77
+ logging: bool = True,
78
+ use_default_config: bool = True,
79
+ ) -> str:
80
+
81
+ with open("../prompt.txt", "w") as f:
82
+ f.write(prompt)
83
+
84
+ generated_text = ""
85
+ tokenized_text: list[int] = self.model.tokenize(text=prompt)
86
+ response: list[int] = self.model.generate(
87
+ tokens=tokenized_text, **settings.local_llm.model_dump()
88
+ )
89
+
90
+ if logging:
91
+ print(response)
92
+
93
+ if not stream:
94
+ return self.model.detokenize(response)
95
+
96
+ for token in response:
97
+ chunk = self.model.detokenize([token])
98
+ generated_text += chunk
99
+ if logging:
100
+ print(chunk, end="", flush=True) # flush -> clear the buffer
101
+
102
+ return generated_text
103
+
104
+
105
+ class GeminiLLM:
106
+ def __init__(self, model="gemini-2.0-flash"):
107
+ self.client = genai.Client(api_key=settings.api_key)
108
+ self.model = model
109
+
110
+ def get_response(
111
+ self,
112
+ prompt: str,
113
+ stream: bool = True,
114
+ logging: bool = True,
115
+ use_default_config: bool = False,
116
+ ) -> str:
117
+ path_to_prompt = os.path.join(BASE_DIR, "prompt.txt")
118
+ with open(path_to_prompt, "w", encoding="utf-8", errors="replace") as f:
119
+ f.write(prompt)
120
+
121
+ response = self.client.models.generate_content(
122
+ model=self.model,
123
+ contents=prompt,
124
+ config=(
125
+ types.GenerateContentConfig(**settings.gemini_generation.model_dump())
126
+ if use_default_config
127
+ else None
128
+ ),
129
+ )
130
+
131
+ return response.text
132
+
133
+ async def get_streaming_response(
134
+ self,
135
+ prompt: str,
136
+ stream: bool = True,
137
+ logging: bool = True,
138
+ use_default_config: bool = False,
139
+ ):
140
+ path_to_prompt = os.path.join(BASE_DIR, "prompt.txt")
141
+ with open(path_to_prompt, "w", encoding="utf-8", errors="replace") as f:
142
+ f.write(prompt)
143
+
144
+ response = self.client.models.generate_content_stream(
145
+ model=self.model,
146
+ contents=prompt,
147
+ config=(
148
+ types.GenerateContentConfig(**settings.gemini_generation.model_dump())
149
+ if use_default_config
150
+ else None
151
+ ),
152
+ )
153
+
154
+ for chunk in response:
155
+ yield chunk
156
+
157
+
158
+ class GeminiEmbed:
159
+ def __init__(self, model="text-embedding-004"):
160
+ self.client = genai.Client(api_key=settings.api_key)
161
+ self.model = model
162
+ self.settings = GeminiEmbeddingSettings()
163
+
164
+ def encode(self, text: str | list[str]) -> list[Tensor]:
165
+
166
+ if isinstance(text, str):
167
+ text = [text]
168
+
169
+ output: list[Tensor] = []
170
+ max_batch_size = 100 # can not be changed due to google restrictions
171
+
172
+ for i in range(0, len(text), max_batch_size):
173
+ batch = text[i : i + max_batch_size]
174
+ response = self.client.models.embed_content(
175
+ model=self.model,
176
+ contents=batch,
177
+ config=types.EmbedContentConfig(
178
+ **settings.gemini_embedding.model_dump()
179
+ ),
180
+ ).embeddings
181
+
182
+ for i, emb in enumerate(response):
183
+ output.append(emb.values)
184
+
185
+ return output
186
+
187
+ def get_vector_dimensionality(self) -> int | None:
188
+ return getattr(self.settings, "output_dimensionality")
189
+
190
+
191
+ class Wrapper:
192
+ def __init__(self, model: str = "gemini-2.0-flash"):
193
+ self.model = model
194
+ self.client = genai.Client(api_key=settings.api_key)
195
+
196
+ def wrap(self, prompt: str) -> str:
197
+ response = self.client.models.generate_content(
198
+ model=self.model,
199
+ contents=prompt,
200
+ config=types.GenerateContentConfig(**settings.gemini_wrapper.model_dump())
201
+ )
202
+
203
+ return response.text
app/core/processor.py ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from langchain_community.document_loaders import (
2
+ PyPDFLoader,
3
+ UnstructuredWordDocumentLoader,
4
+ TextLoader,
5
+ CSVLoader,
6
+ UnstructuredMarkdownLoader,
7
+ )
8
+ from langchain_text_splitters import RecursiveCharacterTextSplitter
9
+ from langchain_core.documents import Document
10
+ from app.core.models import Embedder
11
+ from app.core.chunks import Chunk
12
+ import nltk # used for proper tokenizer workflow
13
+ from uuid import (
14
+ uuid4,
15
+ ) # for generating unique id as hex (uuid4 is used as it generates ids form pseudo random numbers unlike uuid1 and others)
16
+ import numpy as np
17
+ from app.settings import logging, settings
18
+
19
+
20
+ # TODO: replace PDFloader since it is completely unusable OR try to fix it
21
+
22
+
23
+ class DocumentProcessor:
24
+ """
25
+ TODO: determine the most suitable chunk size
26
+
27
+ chunks -> the list of chunks from loaded files
28
+ chunks_unsaved -> the list of recently added chunks that have not been saved to db yet
29
+ processed -> the list of files that were already splitted into chunks
30
+ unprocessed -> !processed
31
+ text_splitter -> text splitting strategy
32
+ """
33
+
34
+ def __init__(self, embedder: Embedder):
35
+ self.chunks: list[Chunk] = []
36
+ self.chunks_unsaved: list[Chunk] = []
37
+ self.processed: list[Document] = []
38
+ self.unprocessed: list[Document] = []
39
+ self.embedder = embedder
40
+ self.text_splitter = RecursiveCharacterTextSplitter(
41
+ **settings.text_splitter.model_dump()
42
+ )
43
+
44
+ """
45
+ Measures cosine between two vectors
46
+ """
47
+
48
+ def cosine_similarity(self, vec1, vec2):
49
+ return vec1 @ vec2 / (np.linalg.norm(vec1) * np.linalg.norm(vec2))
50
+
51
+ """
52
+ Updates a list of the most relevant chunks without interacting with db
53
+ """
54
+
55
+ def update_most_relevant_chunk(
56
+ self,
57
+ chunk: list[np.float64, Chunk],
58
+ relevant_chunks: list[list[np.float64, Chunk]],
59
+ mx_len=15,
60
+ ):
61
+ relevant_chunks.append(chunk)
62
+ for i in range(len(relevant_chunks) - 1, 0, -1):
63
+ if relevant_chunks[i][0] > relevant_chunks[i - 1][0]:
64
+ relevant_chunks[i], relevant_chunks[i - 1] = (
65
+ relevant_chunks[i - 1],
66
+ relevant_chunks[i],
67
+ )
68
+ else:
69
+ break
70
+
71
+ if len(relevant_chunks) > mx_len:
72
+ del relevant_chunks[-1]
73
+
74
+ """
75
+ Loads one file - extracts text from file
76
+
77
+ TODO: Replace UnstructuredWordDocumentLoader with Docx2txtLoader
78
+ TODO: Play with .pdf and text from img extraction
79
+ TODO: Try chunking with llm
80
+
81
+ add_to_unprocessed -> used to add loaded file to the list of unprocessed(unchunked) files if true
82
+ """
83
+
84
+ def load_document(
85
+ self, filepath: str, add_to_unprocessed: bool = False
86
+ ) -> list[Document]:
87
+ loader = None
88
+
89
+ if filepath.endswith(".pdf"):
90
+ loader = PyPDFLoader(
91
+ file_path=filepath
92
+ ) # splits each presentation into slides and processes it as separate file
93
+ elif filepath.endswith(".docx") or filepath.endswith(".doc"):
94
+ # loader = Docx2txtLoader(file_path=filepath) ## try it later, since UnstructuredWordDocumentLoader is extremly slow
95
+ loader = UnstructuredWordDocumentLoader(file_path=filepath)
96
+ elif filepath.endswith(".txt"):
97
+ loader = TextLoader(file_path=filepath)
98
+ elif filepath.endswith(".csv"):
99
+ loader = CSVLoader(file_path=filepath)
100
+ elif filepath.endswith(".json"):
101
+ loader = TextLoader(file_path=filepath)
102
+ elif filepath.endswith(".md"):
103
+ loader = UnstructuredMarkdownLoader(file_path=filepath)
104
+
105
+ if loader is None:
106
+ raise RuntimeError("Unsupported type of file")
107
+
108
+ documents: list[Document] = (
109
+ []
110
+ ) # We can not assign a single value to the document since .pdf are splitted into several files
111
+ try:
112
+ documents = loader.load()
113
+ # print("-" * 100, documents, "-" * 100, sep="\n")
114
+ except Exception:
115
+ raise RuntimeError("File is corrupted")
116
+
117
+ if add_to_unprocessed:
118
+ for doc in documents:
119
+ self.unprocessed.append(doc)
120
+
121
+ return documents
122
+
123
+ """
124
+ Similar to load_document, but for multiple files
125
+
126
+ add_to_unprocessed -> used to add loaded files to the list of unprocessed(unchunked) files if true
127
+ """
128
+
129
+ def load_documents(
130
+ self, documents: list[str], add_to_unprocessed: bool = False
131
+ ) -> list[Document]:
132
+ extracted_documents: list[Document] = []
133
+
134
+ for doc in documents:
135
+ temp_storage: list[Document] = []
136
+
137
+ try:
138
+ temp_storage = self.load_document(
139
+ filepath=doc, add_to_unprocessed=False
140
+ ) # In some cases it should be True, but i can not imagine any :(
141
+ except Exception as e:
142
+ logging.error(
143
+ "Error at load_documents while loading %s", doc, exc_info=e
144
+ )
145
+ continue
146
+
147
+ for extrc_doc in temp_storage:
148
+ extracted_documents.append(extrc_doc)
149
+
150
+ if add_to_unprocessed:
151
+ self.unprocessed.append(extrc_doc)
152
+
153
+ return extracted_documents
154
+
155
+ """
156
+ Generates chunks with recursive splitter from the list of unprocessed files, add files to the list of processed, and clears unprocessed
157
+
158
+ TODO: try to split text with other llm (not really needed, but we should at least try it)
159
+ """
160
+
161
+ def generate_chunks(self, query: str = "", embedding: bool = False):
162
+ most_relevant = []
163
+
164
+ if embedding:
165
+ query_embedded = self.embedder.encode(query)
166
+
167
+ for document in self.unprocessed:
168
+ self.processed.append(document)
169
+
170
+ text: list[str] = self.text_splitter.split_documents([document])
171
+ lines: list[str] = document.page_content.split("\n")
172
+
173
+ for chunk in text:
174
+ start_l, end_l = self.get_start_end_lines(
175
+ splitted_text=lines,
176
+ start_char=chunk.metadata.get("start_index", 0),
177
+ end_char=chunk.metadata.get("start_index", 0)
178
+ + len(chunk.page_content),
179
+ )
180
+
181
+ newChunk = Chunk(
182
+ id=uuid4(),
183
+ filename=document.metadata.get("source", ""),
184
+ page_number=document.metadata.get("page", 0),
185
+ start_index=chunk.metadata.get("start_index", 0),
186
+ start_line=start_l,
187
+ end_line=end_l,
188
+ text=chunk.page_content,
189
+ )
190
+
191
+ if embedding:
192
+ chunk_embedded = self.embedder.encode(newChunk.text)
193
+ similarity = self.cosine_similarity(query_embedded, chunk_embedded)
194
+ self.update_most_relevant_chunk(
195
+ [similarity, newChunk], most_relevant
196
+ )
197
+
198
+ self.chunks.append(newChunk)
199
+ self.chunks_unsaved.append(newChunk)
200
+
201
+ self.unprocessed = []
202
+ return most_relevant
203
+
204
+ """
205
+ Determines the line, were the chunk starts and ends (1-based indexing)
206
+
207
+ Some magic stuff here. To be honest, i understood it after 7th attempt
208
+
209
+ TODO: invent more efficient way
210
+
211
+ splitted_text -> original text splitted by \n
212
+ start_char -> index of symbol, were current chunk starts
213
+ end_char -> index of symbol, were current chunk ends
214
+ debug_mode -> flag, which enables printing useful info about the process
215
+ """
216
+
217
+ def get_start_end_lines(
218
+ self,
219
+ splitted_text: list[str],
220
+ start_char: int,
221
+ end_char: int,
222
+ debug_mode: bool = False,
223
+ ) -> tuple[int, int]:
224
+ if debug_mode:
225
+ logging.info(splitted_text)
226
+
227
+ start, end, char_ct = 0, 0, 0
228
+ iter_count = 1
229
+
230
+ for i, line in enumerate(splitted_text):
231
+ if debug_mode:
232
+ logging.info(
233
+ f"start={start_char}, current={char_ct}, end_current={char_ct + len(line) + 1}, end={end_char}, len={len(line)}, iter={iter_count}\n"
234
+ )
235
+
236
+ if char_ct <= start_char <= char_ct + len(line) + 1:
237
+ start = i + 1
238
+ if char_ct <= end_char <= char_ct + len(line) + 1:
239
+ end = i + 1
240
+ break
241
+
242
+ iter_count += 1
243
+ char_ct += len(line) + 1
244
+
245
+ if debug_mode:
246
+ logging.info(f"result => {start} {end}\n\n\n")
247
+
248
+ return start, end
249
+
250
+ """
251
+ Note: it should be used only once to download tokenizers, futher usage is not recommended
252
+ """
253
+
254
+ def update_nltk(self) -> None:
255
+ nltk.download("punkt")
256
+ nltk.download("averaged_perceptron_tagger")
257
+
258
+ """
259
+ For now the system works as follows: we save recently loaded chunks in two arrays:
260
+ chunks - for all chunks, even for that ones that havn't been saveed to db
261
+ chunks_unsaved - for chunks that have been added recently
262
+ I do not know weather we really need to store all chunks that were added in the
263
+ current session, but chunks_unsaved are used to avoid dublications while saving to db.
264
+ """
265
+
266
+ def clear_unsaved_chunks(self):
267
+ self.chunks_unsaved = []
268
+
269
+ def get_all_chunks(self) -> list[Chunk]:
270
+ return self.chunks
271
+
272
+ """
273
+ If we want to save chunks to db, we need to clear the temp storage to avoid dublications
274
+ """
275
+
276
+ def get_and_save_unsaved_chunks(self) -> list[Chunk]:
277
+ chunks_copy: list[Chunk] = self.chunks.copy()
278
+ self.clear_unsaved_chunks()
279
+ return chunks_copy
280
+
281
+
282
+ if __name__ == "__main__":
283
+ document = DocumentProcessor()
284
+ print(document.__getattribute__())
app/core/rag_generator.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, AsyncGenerator
2
+ from app.core.models import LocalLLM, Embedder, Reranker, GeminiLLM, GeminiEmbed, Wrapper
3
+ from app.core.processor import DocumentProcessor
4
+ from app.core.database import VectorDatabase
5
+ import time
6
+ import os
7
+ from app.settings import settings, BASE_DIR
8
+
9
+
10
+ class RagSystem:
11
+ def __init__(self):
12
+ self.embedder = (
13
+ GeminiEmbed()
14
+ if settings.use_gemini
15
+ else Embedder(model=settings.models.embedder_model)
16
+ )
17
+ self.reranker = Reranker(model=settings.models.reranker_model)
18
+ self.processor = DocumentProcessor(self.embedder)
19
+ self.db = VectorDatabase(embedder=self.embedder)
20
+ self.llm = GeminiLLM() if settings.use_gemini else LocalLLM()
21
+ self.wrapper = Wrapper()
22
+
23
+ """
24
+ Provides a prompt with substituted context from chunks
25
+
26
+ TODO: add template to prompt without docs
27
+ """
28
+
29
+ def get_general_prompt(self, user_prompt: str, collection_name: str) -> str:
30
+ enhanced_prompt = self.enhance_prompt(user_prompt.strip())
31
+
32
+ relevant_chunks = self.db.search(collection_name, query=enhanced_prompt, top_k=30)
33
+ if relevant_chunks is not None and len(relevant_chunks) > 0:
34
+ ranks = self.reranker.rank(query=enhanced_prompt, chunks=relevant_chunks)[
35
+ : min(5, len(relevant_chunks))
36
+ ]
37
+ relevant_chunks = [relevant_chunks[rank["corpus_id"]] for rank in ranks]
38
+ else:
39
+ relevant_chunks = []
40
+
41
+ sources = ""
42
+ prompt = ""
43
+
44
+ for chunk in relevant_chunks:
45
+ citation = (
46
+ f"[Source: {chunk.filename}, "
47
+ f"Page: {chunk.page_number}, "
48
+ f"Lines: {chunk.start_line}-{chunk.end_line}, "
49
+ f"Start: {chunk.start_index}]\n\n"
50
+ )
51
+ sources += f"Original text:\n{chunk.get_raw_text()}\nCitation:{citation}"
52
+
53
+ with open(
54
+ os.path.join(BASE_DIR, "app", "prompt_templates", "test2.txt")
55
+ ) as prompt_file:
56
+ prompt = prompt_file.read()
57
+
58
+ prompt += (
59
+ "**QUESTION**: "
60
+ f"{enhanced_prompt}\n"
61
+ "**CONTEXT DOCUMENTS**:\n"
62
+ f"{sources}\n"
63
+ )
64
+ print(prompt)
65
+ return prompt
66
+
67
+ def enhance_prompt(self, original_prompt: str) -> str:
68
+ path_to_wrapping_prompt = os.path.join(BASE_DIR, "app", "prompt_templates", "wrapper.txt")
69
+ enhanced_prompt = ""
70
+ with open(path_to_wrapping_prompt, "r") as f:
71
+ enhanced_prompt = f.read().replace("[USERS_PROMPT]", original_prompt)
72
+ return self.wrapper.wrap(enhanced_prompt)
73
+
74
+ """
75
+ Splits the list of documents into groups with 'split_by' docs (done to avoid qdrant_client connection error handling), loads them,
76
+ splits into chunks, and saves to db
77
+ """
78
+
79
+ def upload_documents(
80
+ self,
81
+ collection_name: str,
82
+ documents: list[str],
83
+ split_by: int = 3,
84
+ debug_mode: bool = True,
85
+ ) -> None:
86
+
87
+ for i in range(0, len(documents), split_by):
88
+
89
+ if debug_mode:
90
+ print(
91
+ "<"
92
+ + "-" * 10
93
+ + "New document group is taken into processing"
94
+ + "-" * 10
95
+ + ">"
96
+ )
97
+
98
+ docs = documents[i : i + split_by]
99
+
100
+ loading_time = 0
101
+ chunk_generating_time = 0
102
+ db_saving_time = 0
103
+
104
+ print("Start loading the documents")
105
+ start = time.time()
106
+ self.processor.load_documents(documents=docs, add_to_unprocessed=True)
107
+ loading_time = time.time() - start
108
+
109
+ print("Start loading chunk generation")
110
+ start = time.time()
111
+ self.processor.generate_chunks()
112
+ chunk_generating_time = time.time() - start
113
+
114
+ print("Start saving to db")
115
+ start = time.time()
116
+ self.db.store(collection_name, self.processor.get_and_save_unsaved_chunks())
117
+ db_saving_time = time.time() - start
118
+
119
+ if debug_mode:
120
+ print(
121
+ f"loading time = {loading_time}, chunk generation time = {chunk_generating_time}, saving time = {db_saving_time}\n"
122
+ )
123
+
124
+ def extract_text(self, response) -> str:
125
+ text = ""
126
+ try:
127
+ text = response.candidates[0].content.parts[0].text
128
+ except Exception as e:
129
+ print(e)
130
+ return text
131
+
132
+ """
133
+ Produces answer to user's request. First, finds the most relevant chunks, generates prompt with them, and asks llm
134
+ """
135
+
136
+ async def generate_response(
137
+ self, collection_name: str, user_prompt: str, stream: bool = True
138
+ ) -> str:
139
+ general_prompt = self.get_general_prompt(
140
+ user_prompt=user_prompt, collection_name=collection_name
141
+ )
142
+
143
+ return self.llm.get_response(prompt=general_prompt)
144
+
145
+ async def generate_response_stream(
146
+ self, collection_name: str, user_prompt: str, stream: bool = True
147
+ ) -> AsyncGenerator[Any, Any]:
148
+ general_prompt = self.get_general_prompt(
149
+ user_prompt=user_prompt, collection_name=collection_name
150
+ )
151
+
152
+ async for chunk in self.llm.get_streaming_response(
153
+ prompt=general_prompt, stream=True
154
+ ):
155
+ yield self.extract_text(chunk)
156
+
157
+ """
158
+ Produces the list of the most relevant chunkВs
159
+ """
160
+
161
+ def get_relevant_chunks(self, collection_name: str, query):
162
+ relevant_chunks = self.db.search(collection_name, query=query, top_k=15)
163
+ relevant_chunks = [
164
+ relevant_chunks[ranked["corpus_id"]]
165
+ for ranked in self.reranker.rank(query=query, chunks=relevant_chunks)
166
+ ]
167
+ return relevant_chunks
168
+
169
+ def create_new_collection(self, collection_name: str) -> None:
170
+ self.db.create_collection(collection_name)
171
+
172
+ def get_collections_names(self) -> list[str]:
173
+ return self.db.get_collections()
app/core/response_parser.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.core.document_validator import path_is_valid
2
+ import re
3
+
4
+ """
5
+ Replaces the matched regular exp with link via html <a></a>
6
+ """
7
+
8
+
9
+ def create_url(match: re.Match) -> str:
10
+ path: str = match.group(1)
11
+ page: str = match.group(2)
12
+ lines: str = match.group(3)
13
+ start: str = match.group(4)
14
+
15
+ if not path_is_valid(path):
16
+ return ""
17
+
18
+ return f'<a href="/viewer?path={path}&page={page}&lines={lines}&start={start}">[Source]</a>'
19
+
20
+
21
+ """
22
+ Replaces all occurrences of citation pattern with links
23
+ """
24
+
25
+
26
+ def add_links(response: str) -> str:
27
+
28
+ citation_format = r"\[Source:\s*([^,]+?)\s*,\s*Page:\s*(\d+)\s*,\s*Lines:\s*(\d+\s*-\s*\d+)\s*,\s*Start:?\s*(\d+)\]"
29
+ return re.sub(pattern=citation_format, repl=create_url, string=response)
app/core/utils.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi.templating import Jinja2Templates
2
+ from fastapi import Request, UploadFile
3
+
4
+ from app.backend.controllers.chats import list_user_chats, verify_ownership_rights
5
+ from app.backend.controllers.users import get_current_user
6
+ from app.backend.models.users import User
7
+ from app.core.rag_generator import RagSystem
8
+ from app.settings import BASE_DIR
9
+
10
+ from uuid import uuid4
11
+ import markdown
12
+ import os
13
+
14
+ rag = None
15
+
16
+
17
+ # <----------------------- System ----------------------->
18
+ def initialize_rag() -> RagSystem:
19
+ global rag
20
+ if rag is None:
21
+ rag = RagSystem()
22
+ return rag
23
+
24
+
25
+ # <----------------------- Tools ----------------------->
26
+ """
27
+ Updates response context and adds context of navbar (role, instance(or none)) and footer (none)
28
+ """
29
+
30
+
31
+ def extend_context(context: dict, selected: int = None):
32
+ user = get_current_user(context.get("request"))
33
+ navbar = {
34
+ "navbar": False,
35
+ "navbar_path": "components/navbar.html",
36
+ "navbar_context": {
37
+ "chats": [],
38
+ "user": {"role": "user" if user else "guest", "instance": user},
39
+ },
40
+ }
41
+ sidebar = {
42
+ "sidebar": True,
43
+ "sidebar_path": "components/sidebar.html",
44
+ "sidebar_context": {
45
+ "selected": selected if selected is not None else None,
46
+ "chat_groups": list_user_chats(user.id) if user else [],
47
+ },
48
+ }
49
+ footer = {"footer": False, "footer_context": None}
50
+
51
+ context.update(**navbar)
52
+ context.update(**footer)
53
+ context.update(**sidebar)
54
+
55
+ return context
56
+
57
+
58
+ """
59
+ Validates chat viewing permission by comparing user's chats and requested one
60
+ """
61
+
62
+
63
+ def protect_chat(user: User, chat_id: int) -> bool:
64
+ return verify_ownership_rights(user, chat_id)
65
+
66
+
67
+ async def save_documents(
68
+ collection_name: str,
69
+ files: list[UploadFile],
70
+ RAG: RagSystem,
71
+ user: User,
72
+ chat_id: int,
73
+ ) -> None:
74
+ storage = os.path.join(
75
+ BASE_DIR,
76
+ "chats_storage",
77
+ f"user_id={user.id}",
78
+ f"chat_id={chat_id}",
79
+ "documents",
80
+ )
81
+ docs = []
82
+
83
+ if files is None or len(files) == 0:
84
+ return
85
+
86
+ os.makedirs(os.path.join(storage, "pdfs"), exist_ok=True)
87
+
88
+ for file in files:
89
+ content = await file.read()
90
+
91
+ if file.filename.endswith(".pdf"):
92
+ saved_file = os.path.join(storage, "pdfs", str(uuid4()) + ".pdf")
93
+ else:
94
+ saved_file = os.path.join(
95
+ storage, str(uuid4()) + "." + file.filename.split(".")[-1]
96
+ )
97
+
98
+ with open(saved_file, "wb") as f:
99
+ f.write(content)
100
+
101
+ docs.append(saved_file)
102
+
103
+ if len(files) > 0:
104
+ RAG.upload_documents(collection_name, docs)
105
+
106
+
107
+ def get_pdf_path(path: str) -> str:
108
+ parts = path.split("chats_storage")
109
+ if len(parts) < 2:
110
+ return ""
111
+ return "chats_storage" + "".join(parts[1:])
112
+
113
+
114
+ def construct_collection_name(user: User, chat_id: int) -> str:
115
+ return f"user_id_{user.id}_chat_id_{chat_id}"
116
+
117
+
118
+ def create_collection(user: User, chat_id: int, RAG: RagSystem) -> None:
119
+ if RAG is None:
120
+ raise RuntimeError("RAG was not initialized")
121
+
122
+ RAG.create_new_collection(construct_collection_name(user, chat_id))
123
+ print(rag.get_collections_names())
124
+
125
+
126
+ def lines_to_markdown(lines: list[str]) -> list[str]:
127
+ return [markdown.markdown(line) for line in lines]
128
+
129
+
130
+ # <----------------------- Handlers ----------------------->
131
+ def PDFHandler(
132
+ request: Request, path: str, page: int, templates
133
+ ) -> Jinja2Templates.TemplateResponse:
134
+ print(path)
135
+ url_path = get_pdf_path(path=path)
136
+ print(url_path)
137
+
138
+ current_template = "pages/show_pdf.html"
139
+ return templates.TemplateResponse(
140
+ current_template,
141
+ extend_context(
142
+ {
143
+ "request": request,
144
+ "page": str(page or 1),
145
+ "url_path": url_path,
146
+ "user": get_current_user(request),
147
+ }
148
+ ),
149
+ )
150
+
151
+
152
+ def TextHandler(
153
+ request: Request, path: str, lines: str, templates
154
+ ) -> Jinja2Templates.TemplateResponse:
155
+ file_content = ""
156
+ with open(path, "r") as f:
157
+ file_content = f.read()
158
+
159
+ start_line, end_line = map(int, lines.split("-"))
160
+
161
+ text_before_citation = []
162
+ text_after_citation = []
163
+ citation = []
164
+ anchor_added = False
165
+
166
+ for index, line in enumerate(file_content.split("\n")):
167
+ if line == "" or line == "\n":
168
+ continue
169
+ if index + 1 < start_line:
170
+ text_before_citation.append(line)
171
+ elif end_line < index + 1:
172
+ text_after_citation.append(line)
173
+ else:
174
+ anchor_added = True
175
+ citation.append(line)
176
+
177
+ current_template = "pages/show_text.html"
178
+
179
+ return templates.TemplateResponse(
180
+ current_template,
181
+ extend_context(
182
+ {
183
+ "request": request,
184
+ "text_before_citation": lines_to_markdown(text_before_citation),
185
+ "text_after_citation": lines_to_markdown(text_after_citation),
186
+ "citation": lines_to_markdown(citation),
187
+ "anchor_added": anchor_added,
188
+ "user": get_current_user(request),
189
+ }
190
+ ),
191
+ )
192
+
193
+
194
+ """
195
+ Optional handler
196
+ """
197
+
198
+
199
+ def DocHandler():
200
+ pass
app/email_templates/password_reset.html ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <html lang="en">
2
+ <head>
3
+ <style>
4
+ body {{
5
+ font-family: Arial, sans-serif;
6
+ line-height: 1.6;
7
+ color: #333333;
8
+ margin: 0;
9
+ padding: 0;
10
+ background-color: #f4f4f4;
11
+ }}
12
+
13
+ .container {{
14
+ max-width: 600px;
15
+ margin: 20px auto;
16
+ padding: 20px;
17
+ background: #ffffff;
18
+ border-radius: 8px;
19
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
20
+ }}
21
+
22
+ .header {{
23
+ background-color: #007bff;
24
+ color: #ffffff;
25
+ padding: 10px 20px;
26
+ border-top-left-radius: 8px;
27
+ border-top-right-radius: 8px;
28
+ text-align: center;
29
+ }}
30
+
31
+ .content {{
32
+ padding: 20px;
33
+ }}
34
+
35
+ .button {{
36
+ display: inline-block;
37
+ background-color: #28a745;
38
+ color: #ffffff;
39
+ padding: 10px 20px;
40
+ border-radius: 5px;
41
+ text-decoration: none;
42
+ margin-top: 15px;
43
+ }}
44
+
45
+ .footer {{
46
+ text-align: center;
47
+ margin-top: 20px;
48
+ font-size: 1.2em;
49
+ color: #777777;
50
+ }}
51
+
52
+ p {{
53
+ margin-bottom: 15px;
54
+ }}
55
+ </style>
56
+ </head>
57
+ <body>
58
+ <div class="container">
59
+ <div class="header">
60
+ <h2>Password Reset Request</h2>
61
+ </div>
62
+ <div class="content">
63
+ <p>Hello,</p>
64
+ <p>We received a request to reset your password for your account. If you initiated this request, please click on
65
+ the link below to set a new password:</p>
66
+ <p style="text-align: center;">
67
+ <a href="{application_server_url}?token={reset_token}" class="button">Reset Your Password</a>
68
+ </p>
69
+ <p>This password reset link is valid for <b>{expires_in} minutes</b>. For security
70
+ reasons, if you do not reset your password within this timeframe, you will need to submit another
71
+ request.</p>
72
+ <p>If you did not request a password reset, please ignore this email. Your password will remain unchanged.</p>
73
+ <p>Thanks,</p>
74
+ </div>
75
+ <div class="footer">
76
+ <p>The Ultimate RAG Team</p>
77
+ </div>
78
+ </div>
79
+ </body>
80
+ </html>
app/frontend/static/styles.css ADDED
@@ -0,0 +1,377 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #pdf-container {
2
+ margin: 0 auto;
3
+ max-width: 100%;
4
+ overflow-x: auto;
5
+ text-align: center;
6
+ padding: 20px 0;
7
+ }
8
+
9
+ #pdf-canvas {
10
+ margin: 0 auto;
11
+ display: block;
12
+ max-width: 100%;
13
+ box-shadow: 0 0 5px rgba(0,0,0,0.2);
14
+ }
15
+
16
+ #pageNum {
17
+ height: 40px; /* optional */
18
+ font-size: 16px; /* makes text inside input larger */
19
+ padding: 10px;
20
+ width: 9vh; /* optional for more padding inside the box */
21
+ }
22
+
23
+ .page-input {
24
+ width: 60px;
25
+ padding: 8px;
26
+ padding-right: 40px; /* reserve space for label inside input box */
27
+ text-align: center;
28
+ border: 1px solid #ddd;
29
+ border-radius: 4px;
30
+ -moz-appearance: textfield;
31
+ }
32
+
33
+ .page-input-label {
34
+ position: absolute;
35
+ right: 12px;
36
+ top: 50%;
37
+ transform: translateY(-50%);
38
+ font-size: 12px;
39
+ color: #666;
40
+ pointer-events: none;
41
+ background-color: #fff; /* Match background to prevent text overlapping */
42
+ padding-left: 4px;
43
+ }
44
+
45
+ .page-input-container {
46
+ position: relative;
47
+ display: inline-flex;
48
+ align-items: center;
49
+ }
50
+
51
+ /* Hide number arrows in Chrome/Safari */
52
+ .page-input::-webkit-outer-spin-button,
53
+ .page-input::-webkit-inner-spin-button {
54
+ -webkit-appearance: none;
55
+ margin: 0;
56
+ }
57
+
58
+ /* Pagination styling */
59
+ .pagination-container {
60
+ margin: 20px 0;
61
+ text-align: center;
62
+ }
63
+
64
+ .pagination {
65
+ display: inline-flex;
66
+ align-items: center;
67
+ }
68
+
69
+ .pagination-button {
70
+ padding: 8px 16px;
71
+ background: #4a6fa5;
72
+ color: white;
73
+ border: none;
74
+ border-radius: 4px;
75
+ cursor: pointer;
76
+ display: flex;
77
+ align-items: center;
78
+ gap: 5px;
79
+ }
80
+
81
+ .pagination-button-text:hover {
82
+ background-color: #e0e0e0;
83
+ transform: translateY(-1px);
84
+ }
85
+
86
+ .pagination-button-text:active {
87
+ transform: translateY(0);
88
+ }
89
+
90
+ .text-viewer {
91
+ overflow-y: auto; /* Enables vertical scrolling when needed */
92
+ height: 100%;
93
+ width: 100%; /* Or whatever height you prefer */
94
+ font-family: monospace;
95
+ white-space: pre-wrap; /* Preserve line breaks but wrap text */
96
+ background: #f8f8f8;
97
+ padding: 20px;
98
+ border-radius: 5px;
99
+ line-height: 1.5;
100
+ }
101
+
102
+ .citation {
103
+ background-color: rgba(0, 255, 0, 0.2);
104
+ padding: 2px 0;
105
+ }
106
+
107
+ .no-content {
108
+ color: #999;
109
+ font-style: italic;
110
+ }
111
+
112
+ .pagination-container-text {
113
+ margin: 20px 0;
114
+ text-align: center;
115
+ }
116
+
117
+ .pagination-button-text {
118
+ padding: 8px 16px;
119
+ background: #4a6fa5;
120
+ color: white;
121
+ border: none;
122
+ border-radius: 4px;
123
+ cursor: pointer;
124
+ }
125
+
126
+
127
+
128
+ /* -------------------------------------------- */
129
+
130
+ body {
131
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
132
+ background-color: #f7f7f8;
133
+ color: #111827;
134
+ margin: 0;
135
+ overflow: hidden;
136
+ height: 100vh;
137
+ padding: 0;
138
+ display: flex;
139
+ }
140
+
141
+ .sidebar {
142
+ width: 260px;
143
+ height: 100vh;
144
+ background-color: #1F2937;
145
+ /* border-right: 1px solid #e1e4e8; */
146
+ overflow-y: auto;
147
+ padding: 8px;
148
+ position: sticky;
149
+ top: 0;
150
+ }
151
+
152
+ .chat-page {
153
+ background-color: #111827;
154
+ flex: 1;
155
+ display: flex;
156
+ flex-direction: column;
157
+ height: 100vh;
158
+ overflow: hidden; /* Prevent double scrollbars */
159
+ }
160
+
161
+ .container {
162
+ flex: 1;
163
+ display: flex;
164
+ flex-direction: column;
165
+ padding: 0;
166
+ max-width: 100%;
167
+ height: 100%;
168
+ }
169
+
170
+ /* Chat messages section */
171
+ .chat-messages {
172
+ flex: 1;
173
+ overflow-y: auto; /* Make only this section scrollable */
174
+ padding: 16px;
175
+ display: flex;
176
+ flex-direction: column;
177
+ gap: 16px;
178
+ }
179
+
180
+ /* Input area - stays fixed at bottom */
181
+ .input-group {
182
+ /* padding: 16px;
183
+ background-color: #44444C; */
184
+ /* border-top: 1px solid #e1e4e8; */
185
+ position: sticky;
186
+ bottom: 0;
187
+ }
188
+
189
+ /* General styles */
190
+
191
+ /* Sidebar styles */
192
+
193
+ .chat-group {
194
+ font-weight: 500;
195
+ color: #9bb8d3;
196
+ text-transform: uppercase;
197
+ letter-spacing: 0.5px;
198
+ font-size: 12px;
199
+ padding: 8px 12px;
200
+ }
201
+
202
+ .btn {
203
+ border-radius: 10px;
204
+ padding: 8px 12px;
205
+ font-size: 14px;
206
+ transition: all 0.2s;
207
+ }
208
+
209
+ .btn-success {
210
+ background-color: #19c37d;
211
+ border-color: #19c37d;
212
+ }
213
+
214
+ .btn-success:hover {
215
+ background-color: #16a369;
216
+ border-color: #16a369;
217
+ }
218
+
219
+ .btn-outline-secondary {
220
+ /* border-color: #e1e4e8; */
221
+ color: #374151;
222
+ background-color: transparent;
223
+ }
224
+
225
+ .btn-outline-secondary:hover {
226
+ background-color: #273c50;
227
+ border-color: #e1e4e8;
228
+ }
229
+
230
+ .btn-outline-light {
231
+ border-color: #e1e4e8;
232
+ color: #666;
233
+ background-color: transparent;
234
+ }
235
+
236
+ .btn-outline-light:hover {
237
+ background-color: #e9ecef;
238
+ border-color: #e1e4e8;
239
+ }
240
+
241
+ /* Chat page styles */
242
+
243
+ .message {
244
+ max-width: 80%;
245
+ padding: 12px 16px;
246
+ border-radius: 12px;
247
+ line-height: 1.5;
248
+ }
249
+
250
+ .user-message {
251
+ align-self: flex-end;
252
+ background-color: #19c37d;
253
+ color: white;
254
+ border-bottom-right-radius: 4px;
255
+ }
256
+
257
+ .assistant-message {
258
+ align-self: flex-start;
259
+ background-color: #f0f4f8;
260
+ border-bottom-left-radius: 4px;
261
+ }
262
+
263
+ .message-header {
264
+ font-weight: 600;
265
+ font-size: 12px;
266
+ margin-bottom: 4px;
267
+ color: #666;
268
+ }
269
+
270
+ .user-message .message-header {
271
+ color: rgba(255, 255, 255, 0.8);
272
+ }
273
+
274
+ .message-content {
275
+ font-size: 14px;
276
+ }
277
+
278
+
279
+ .form-control {
280
+ border-radius: 6px;
281
+ padding: 10px 12px;
282
+ background-color: #374151;
283
+ /* border: 1px solid #e1e4e8; */
284
+ }
285
+
286
+ .form-control:focus {
287
+ box-shadow: none;
288
+ border-color: #19c37d;
289
+ }
290
+
291
+ /* File input button */
292
+ .btn-outline-secondary {
293
+ position: relative;
294
+ }
295
+
296
+ .btn-outline-secondary input[type="file"] {
297
+ position: absolute;
298
+ opacity: 0;
299
+ width: 100%;
300
+ height: 100%;
301
+ top: 0;
302
+ left: 0;
303
+ cursor: pointer;
304
+ }
305
+
306
+ /* Scrollbar styles */
307
+ ::-webkit-scrollbar {
308
+ width: 8px;
309
+ }
310
+
311
+ ::-webkit-scrollbar-track {
312
+ background: #f1f1f1;
313
+ }
314
+
315
+ ::-webkit-scrollbar-thumb {
316
+ background: #ccc;
317
+ border-radius: 4px;
318
+ }
319
+
320
+ ::-webkit-scrollbar-thumb:hover {
321
+ background: #aaa;
322
+ }
323
+
324
+ /* Responsive adjustments */
325
+ @media (max-width: 768px) {
326
+ .sidebar {
327
+ width: 220px;
328
+ }
329
+
330
+ .message {
331
+ max-width: 90%;
332
+ }
333
+ }
334
+
335
+ #queryInput {
336
+ background-color: #374151;
337
+ color: white;
338
+ }
339
+
340
+ #queryInput:focus {
341
+ background-color: #374151;
342
+ color: white;
343
+ outline: none;
344
+ box-shadow: none;
345
+ border-color: #19c37d; /* optional green border for focus, remove if unwanted */
346
+ }
347
+
348
+ #searchButton {
349
+ background-color: #374151;
350
+ }
351
+
352
+ #fileInput {
353
+ background-color: #374151;
354
+ }
355
+
356
+
357
+ /* For the placeholder text color */
358
+ #queryInput::placeholder {
359
+ color: rgba(255, 255, 255, 0.7); /* Slightly transparent white */
360
+ }
361
+
362
+ .auth-card {
363
+ background-color: #1F2937;
364
+ border: none;
365
+ border-radius: 12px;
366
+ }
367
+
368
+ .auth-input {
369
+ background-color: #374151 !important;
370
+ border: none !important;
371
+ color: white !important;
372
+ }
373
+
374
+ .auth-input-group-text {
375
+ background-color: #374151 !important;
376
+ border: none !important;
377
+ }
app/frontend/templates/base.html ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ {% block title %}
7
+ {% endblock %}
8
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
9
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css" rel="stylesheet">
10
+ <link href="/static/styles.css" rel="stylesheet">
11
+ {% block head_scripts %}
12
+ {% endblock %}
13
+ </head>
14
+ <body>
15
+ {% if navbar %}
16
+ {% with context=navbar_context %}
17
+ {% include navbar_path %}
18
+ {% endwith %}
19
+ {% endif %}
20
+
21
+ {% if sidebar %}
22
+ {% with context=sidebar_context %}
23
+ {% include sidebar_path %}
24
+ {% endwith %}
25
+ {% endif %}
26
+
27
+ {% block content %}
28
+ {% with context=sidebar_context %}
29
+ {% include sidebar_path %}
30
+ {% endwith %}
31
+ {% endblock %}
32
+
33
+ {% if footer %}
34
+ {% with context=footer_context %}
35
+ {% include footer_path %}
36
+ {% endwith %}
37
+ {% endif %}
38
+
39
+ {% block body_scripts %}
40
+ {% endblock %}
41
+ </body>
42
+ </html>
app/frontend/templates/components/navbar.html ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!-- All the data is accessible via context -->
2
+ <div>
3
+ {% if context.user.role == "guest" %}
4
+ <p>Hello, guest!</p>
5
+ {% else %}
6
+ <p>Hello, {{ context.user.instance.email }}</p>
7
+ {% endif %}
8
+
9
+ <p>Today</p>
10
+ <ul>
11
+ {% for chat in context.chats.today %}
12
+ <li>{{ chat.title }}</li>
13
+ {% endfor %}
14
+ </ul>
15
+ <p>Last week</p>
16
+ <ul>
17
+ {% for chat in context.chats.last_week %}
18
+ <li>{{ chat.title }}</li>
19
+ {% endfor %}
20
+ </ul>
21
+ <p>Last month</p>
22
+ <ul>
23
+ {% for chat in context.chats.last_month %}
24
+ <li>{{ chat.title }}</li>
25
+ {% endfor %}
26
+ </ul>
27
+ <p>Later</p>
28
+ <ul>
29
+ {% for chat in context.chats.other %}
30
+ <li>{{ chat.title }}</li>
31
+ {% endfor %}
32
+ </ul>
33
+ </div>
app/frontend/templates/components/sidebar.html ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div class="sidebar">
2
+ <div class="d-flex justify-content-between align-items-center p-3">
3
+ <form action="/new_chat" method="post">
4
+ <button type="submit" class="btn btn-success w-100">+ Add new chat</button>
5
+ </form>
6
+ </div>
7
+
8
+ {% if context.chat_groups %}
9
+ {% for group in context.chat_groups %}
10
+ <div class="chat-group px-3 text mt-3">{{ group.title }}</div>
11
+ {% for chat in group.chats %}
12
+ <form action="/chats/id={{ chat.id }}" method="get" class="px-3 my-1">
13
+ {% if context.selected == chat.id %}
14
+ <button type="submit" class="btn btn-outline-secondary w-100 text-start text-truncate text-success">
15
+ {{ chat.title }}
16
+ </button>
17
+ {% else %}
18
+ <button type="submit" class="btn btn-outline-secondary w-100 text-start text-truncate text-white">
19
+ {{ chat.title }}
20
+ </button>
21
+ {% endif %}
22
+ </form>
23
+ {% endfor %}
24
+ {% endfor %}
25
+ {% endif %}
26
+ </div>
app/frontend/templates/pages/chat.html ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}
4
+ <title>
5
+ The Ultimate RAG
6
+ </title>
7
+ {% endblock %}
8
+
9
+ {% block content %}
10
+ <div class="chat-page">
11
+ <div class="container py-4">
12
+ <div id="chat-messages" class="chat-messages">
13
+ <!-- {% for message in history %}
14
+ <div class="message {{ message.role }}-message">
15
+ <div class="message-header">
16
+ {{ "You" if message.role == "user" else "Assistant" }}
17
+ </div>
18
+ <div class="message-content">{{ message.content | safe }}</div>
19
+ </div>
20
+ {% endfor %} -->
21
+ </div>
22
+
23
+ <form id="chat-form" class="input-group mt-4" enctype="multipart/form-data">
24
+ <input type="text" class="form-control" name="prompt" placeholder="Ask your question here" id="queryInput">
25
+ <label class="btn btn-outline-secondary btn-primary">
26
+ 📎<input type="file" id="fileInput" name="files" multiple hidden>
27
+ </label>
28
+ <button type="button" class="btn text-white" id="searchButton">Send</button>
29
+ </form>
30
+ </div>
31
+ </div>
32
+ {% endblock %}
33
+
34
+ {% block body_scripts %}
35
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
36
+ <script>
37
+ const initialChatId = "{{ chat_id }}";
38
+ const initialHistory = {{ history | tojson | safe }};
39
+ // Conversation state
40
+ let conversationId = initialChatId || null;
41
+
42
+ if (initialHistory && Array.isArray(initialHistory)) {
43
+ initialHistory.forEach(msg => {
44
+ addMessageToChat(msg.role, msg.content);
45
+ });
46
+ }
47
+
48
+ // Main chat function
49
+ document.getElementById('searchButton').addEventListener('click', async function() {
50
+ const query = document.getElementById('queryInput').value.trim();
51
+ if (!query) return alert('Please enter a question');
52
+
53
+ addMessageToChat('user', escapeHTML(query));
54
+ document.getElementById('queryInput').value = '';
55
+ const loadingId = addMessageToChat('assistant', '', true);
56
+
57
+ try {
58
+ const formData = new FormData();
59
+ const fileInput = document.getElementById('fileInput');
60
+ const files = fileInput.files;
61
+ for (let i = 0; i < files.length; i++) {
62
+ formData.append('files', files[i]);
63
+ }
64
+ formData.append('prompt', query);
65
+ if (conversationId) formData.append('chat_id', conversationId);
66
+
67
+ const response = await fetch('/message_with_docs', {
68
+ method: 'POST',
69
+ body: formData
70
+ });
71
+
72
+ if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
73
+
74
+ const reader = response.body.getReader();
75
+ const decoder = new TextDecoder("utf-8");
76
+ let fullMessage = "";
77
+
78
+ while (true) {
79
+ const { value, done } = await reader.read();
80
+ if (done) break;
81
+
82
+ const chunk = decoder.decode(value, { stream: true });
83
+ fullMessage += chunk;
84
+ updateMessageContent(loadingId, marked.parse(fullMessage));
85
+ }
86
+
87
+ removeMessage(loadingId);
88
+ const finalId = addMessageToChat('assistant', marked.parse(fullMessage));
89
+
90
+ try {
91
+ const response = await fetch('/replace_message', {
92
+ method: 'POST',
93
+ headers: { "Content-Type": "application/json" },
94
+ body: JSON.stringify({ message: fullMessage, chat_id: initialChatId })
95
+ });
96
+
97
+ if (!response.ok) throw new Error(`Replace error: ${response.status}`);
98
+
99
+ const data = await response.json(); // expects { "updated_message": "..." }
100
+
101
+ updateMessageContent(finalId, marked.parse(data.updated_message));
102
+
103
+ } catch (error) {
104
+ console.error("Error replacing message:", error);
105
+ }
106
+
107
+ } catch (error) {
108
+ removeMessage(loadingId);
109
+ addMessageToChat('assistant', `Error: ${error.message}`, false, 'error');
110
+ console.error('Error:', error);
111
+ }
112
+ });
113
+
114
+ function updateMessageContent(messageId, newContent) {
115
+ const element = document.getElementById(messageId);
116
+ if (element) {
117
+ const contentDiv = element.querySelector('.message-content');
118
+ if (contentDiv) contentDiv.innerHTML = newContent;
119
+ }
120
+ }
121
+
122
+
123
+ // Message display helper
124
+ function addMessageToChat(role, content, isTemporary = false, className = '') {
125
+ const chatMessages = document.getElementById('chat-messages');
126
+ const messageId = 'msg-' + Date.now();
127
+
128
+ const messageDiv = document.createElement('div');
129
+ messageDiv.className = `message ${role}-message ${className}`;
130
+ messageDiv.id = messageId;
131
+
132
+ messageDiv.innerHTML = `
133
+ <div class="message-header">${role === 'user' ? 'You' : 'Assistant'}</div>
134
+ <div class="message-content">${marked.parse(content)}</div>
135
+ `;
136
+
137
+ chatMessages.appendChild(messageDiv);
138
+ chatMessages.scrollTop = chatMessages.scrollHeight;
139
+
140
+ return messageId; // always return the ID so you can update it later
141
+ }
142
+
143
+
144
+ function removeMessage(messageId) {
145
+ const element = document.getElementById(messageId);
146
+ if (element) element.remove();
147
+ }
148
+
149
+ function escapeHTML(str) {
150
+ const div = document.createElement('div');
151
+ div.textContent = str;
152
+ return div.innerHTML;
153
+ }
154
+ // New chat handler
155
+ document.querySelector('form[action="/new_chat"]').addEventListener('submit', function(e) {
156
+ e.preventDefault();
157
+ conversationId = null;
158
+ conversationHistory = [];
159
+ document.getElementById('chat-messages').innerHTML = '';
160
+ this.submit();
161
+ });
162
+ </script>
163
+ {% endblock %}