march 6
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .coverage +0 -0
- .gitignore +2 -1
- README.md +36 -0
- champ/rag.py +2 -1
- champ/service.py +13 -5
- classes/base_models.py +20 -2
- classes/ocr_reader.py +27 -0
- classes/pii_filter.py +50 -4
- classes/session_conversation_store.py +2 -5
- classes/session_document_store.py +11 -18
- classes/session_tracker.py +0 -1
- conftest.py +10 -0
- constants.py +6 -3
- exceptions.py +61 -0
- helpers/file_helper.py +176 -35
- helpers/lifespan_helper.py +52 -0
- helpers/llm_helper.py +129 -0
- helpers/message_helper.py +54 -0
- main.py +91 -367
- pytest.ini +14 -0
- requirements-dev.txt +7 -0
- static/app.js +36 -749
- static/components/chat-component.js +276 -0
- static/components/comment-component.js +120 -0
- static/components/consent-component.js +50 -0
- static/components/feedback-component.js +200 -0
- static/components/file-upload-component.js +273 -0
- static/components/language-component.js +58 -0
- static/components/profile-component.js +108 -0
- static/components/settings-component.js +118 -0
- static/services/api-service.js +201 -0
- static/services/state-manager.js +145 -0
- static/services/translation-service.js +48 -0
- static/styles/base.css +359 -0
- static/styles/components/chat.css +133 -0
- static/styles/components/comment.css +50 -0
- static/styles/components/consent.css +6 -0
- static/styles/components/feedback.css +198 -0
- static/styles/components/file-upload.css +68 -0
- static/styles/components/settings.css +58 -0
- static/styles/control-bar.css +38 -0
- static/styles/snackbar.css +51 -0
- static/translations.js +81 -18
- static/utils.js +56 -0
- templates/index.html +98 -32
- tests/api/conftest.py +16 -0
- tests/api/test_chat_post.py +467 -0
- tests/api/test_comment_post.py +237 -0
- tests/api/test_feedback_post.py +99 -0
- tests/api/test_file_delete.py +511 -0
.coverage
ADDED
|
Binary file (53.2 kB). View file
|
|
|
.gitignore
CHANGED
|
@@ -4,4 +4,5 @@ __pycache__/
|
|
| 4 |
venv/
|
| 5 |
.env
|
| 6 |
.venv*/
|
| 7 |
-
conversations.json
|
|
|
|
|
|
| 4 |
venv/
|
| 5 |
.env
|
| 6 |
.venv*/
|
| 7 |
+
conversations.json
|
| 8 |
+
/.coverage
|
README.md
CHANGED
|
@@ -75,6 +75,42 @@ To update the code in the space, click on `+ Contribute` button in the upper-rig
|
|
| 75 |
|
| 76 |
You could add the Git repo as a remote to your local Git repository, but it would add unnecessary complexity. HuggingFace is stricter than Gitlab concerning best Git practices. You would have to configure `git-xet` and delete the `.env` file and the binary file in `rag_data` from the Git history to be able to push your changes. The `.env` file has not been added to the space. The environment variables are stored in the settings page.
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
## Load testing
|
| 80 |
[k6](https://k6.io/open-source/) is an open-source tool for performing load testing. Test cases are defined in JavaScript files and can be run using the command `k6 run <filename>.js`.
|
|
|
|
| 75 |
|
| 76 |
You could add the Git repo as a remote to your local Git repository, but it would add unnecessary complexity. HuggingFace is stricter than Gitlab concerning best Git practices. You would have to configure `git-xet` and delete the `.env` file and the binary file in `rag_data` from the Git history to be able to push your changes. The `.env` file has not been added to the space. The environment variables are stored in the settings page.
|
| 77 |
|
| 78 |
+
## Unit testing
|
| 79 |
+
To run the tests, simply execute `pytest` at the project root. Make sure your virtual environment is activated and that dev dependencies are installed:
|
| 80 |
+
```bash
|
| 81 |
+
pip install -r requirements-dev.txt
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
Some tests are marked as `resource_intensive`. They take longer to run and might consume significant memory or CPU. To run them:
|
| 85 |
+
```bash
|
| 86 |
+
pytest -m resource_intensive
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
To run every test:
|
| 90 |
+
```bash
|
| 91 |
+
pytest -m ""
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
### Code coverage
|
| 95 |
+
`coverage` is a Python library that measures code coverage. To use it, run:
|
| 96 |
+
```bash
|
| 97 |
+
coverage run -m pytest
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
To see a short summary of the results, run:
|
| 101 |
+
```bash
|
| 102 |
+
coverage report
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
For a more detailed presentation, run:
|
| 106 |
+
```bash
|
| 107 |
+
coverage html
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
To run `pytest` with additionnal arguments, you can run, for example:
|
| 111 |
+
```bash
|
| 112 |
+
coverage run -m pytest -m resource_intensive
|
| 113 |
+
```
|
| 114 |
|
| 115 |
## Load testing
|
| 116 |
[k6](https://k6.io/open-source/) is an open-source tool for performing load testing. Test cases are defined in JavaScript files and can be run using the command `k6 run <filename>.js`.
|
champ/rag.py
CHANGED
|
@@ -46,7 +46,7 @@ def load_vector_store(
|
|
| 46 |
def create_session_vector_store(
|
| 47 |
base_vector_store: LCFAISS,
|
| 48 |
embedding_model: HuggingFaceEmbeddings,
|
| 49 |
-
|
| 50 |
):
|
| 51 |
# Only deep copy the FAISS index, not the embedding model
|
| 52 |
index_copy = faiss.clone_index(base_vector_store.index)
|
|
@@ -58,6 +58,7 @@ def create_session_vector_store(
|
|
| 58 |
index_to_docstore_id=copy.deepcopy(base_vector_store.index_to_docstore_id),
|
| 59 |
)
|
| 60 |
|
|
|
|
| 61 |
text_splitter = RecursiveCharacterTextSplitter()
|
| 62 |
document_chunks = text_splitter.split_documents(documents)
|
| 63 |
|
|
|
|
| 46 |
def create_session_vector_store(
|
| 47 |
base_vector_store: LCFAISS,
|
| 48 |
embedding_model: HuggingFaceEmbeddings,
|
| 49 |
+
document_contents: List[str],
|
| 50 |
):
|
| 51 |
# Only deep copy the FAISS index, not the embedding model
|
| 52 |
index_copy = faiss.clone_index(base_vector_store.index)
|
|
|
|
| 58 |
index_to_docstore_id=copy.deepcopy(base_vector_store.index_to_docstore_id),
|
| 59 |
)
|
| 60 |
|
| 61 |
+
documents = [Document(document_text) for document_text in document_contents]
|
| 62 |
text_splitter = RecursiveCharacterTextSplitter()
|
| 63 |
document_chunks = text_splitter.split_documents(documents)
|
| 64 |
|
champ/service.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
# app/champ/service.py
|
| 2 |
|
|
|
|
| 3 |
from typing import Any, Dict, List, Literal, Optional, Sequence, Tuple
|
| 4 |
|
| 5 |
from langchain_community.vectorstores import FAISS as LCFAISS
|
|
@@ -8,6 +9,8 @@ from langchain_core.messages import HumanMessage
|
|
| 8 |
from .agent import build_champ_agent
|
| 9 |
from .triage import safety_triage
|
| 10 |
|
|
|
|
|
|
|
| 11 |
|
| 12 |
class ChampService:
|
| 13 |
vector_store: Optional[LCFAISS] = None
|
|
@@ -33,6 +36,7 @@ class ChampService:
|
|
| 33 |
Tuple[str, Dict[str, Any], List[str]]: The replay, the triage_triggered object and the retrieved passages
|
| 34 |
"""
|
| 35 |
if self.agent is None:
|
|
|
|
| 36 |
raise RuntimeError("CHAMP is not initialized yet.")
|
| 37 |
# --- Safety triage micro-layer (before LLM) ---
|
| 38 |
last_user_text = None
|
|
@@ -43,11 +47,15 @@ class ChampService:
|
|
| 43 |
|
| 44 |
if last_user_text:
|
| 45 |
triggered, override_reply, reason = safety_triage(last_user_text)
|
| 46 |
-
if triggered:
|
| 47 |
-
return
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
result = self.agent.invoke({"messages": list(lc_messages)})
|
| 53 |
|
|
|
|
| 1 |
# app/champ/service.py
|
| 2 |
|
| 3 |
+
import logging
|
| 4 |
from typing import Any, Dict, List, Literal, Optional, Sequence, Tuple
|
| 5 |
|
| 6 |
from langchain_community.vectorstores import FAISS as LCFAISS
|
|
|
|
| 9 |
from .agent import build_champ_agent
|
| 10 |
from .triage import safety_triage
|
| 11 |
|
| 12 |
+
logger = logging.getLogger("uvicorn")
|
| 13 |
+
|
| 14 |
|
| 15 |
class ChampService:
|
| 16 |
vector_store: Optional[LCFAISS] = None
|
|
|
|
| 36 |
Tuple[str, Dict[str, Any], List[str]]: The replay, the triage_triggered object and the retrieved passages
|
| 37 |
"""
|
| 38 |
if self.agent is None:
|
| 39 |
+
logger.error("CHAMP invoked before initialization")
|
| 40 |
raise RuntimeError("CHAMP is not initialized yet.")
|
| 41 |
# --- Safety triage micro-layer (before LLM) ---
|
| 42 |
last_user_text = None
|
|
|
|
| 47 |
|
| 48 |
if last_user_text:
|
| 49 |
triggered, override_reply, reason = safety_triage(last_user_text)
|
| 50 |
+
if triggered and override_reply is not None:
|
| 51 |
+
return (
|
| 52 |
+
override_reply,
|
| 53 |
+
{
|
| 54 |
+
"triage_triggered": True,
|
| 55 |
+
"triage_reason": reason,
|
| 56 |
+
},
|
| 57 |
+
[], # No retrieved documents
|
| 58 |
+
)
|
| 59 |
|
| 60 |
result = self.agent.invoke({"messages": list(lc_messages)})
|
| 61 |
|
classes/base_models.py
CHANGED
|
@@ -5,9 +5,10 @@ from constants import (
|
|
| 5 |
MAX_FILE_NAME_LENGTH,
|
| 6 |
MAX_ID_LENGTH,
|
| 7 |
MAX_MESSAGE_LENGTH,
|
|
|
|
| 8 |
)
|
| 9 |
from pydantic import BaseModel, Field, field_validator
|
| 10 |
-
from typing import
|
| 11 |
|
| 12 |
|
| 13 |
class IdentifierBase(BaseModel):
|
|
@@ -46,6 +47,23 @@ class ChatRequest(IdentifierBase, ProfileBase):
|
|
| 46 |
return nh3.clean(human_message)
|
| 47 |
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
class CommentRequest(IdentifierBase, ProfileBase):
|
| 50 |
comment: str = Field(min_length=1, max_length=MAX_COMMENT_LENGTH)
|
| 51 |
|
|
@@ -58,7 +76,7 @@ class CommentRequest(IdentifierBase, ProfileBase):
|
|
| 58 |
class DeleteFileRequest(IdentifierBase, ProfileBase):
|
| 59 |
file_name: str = Field(
|
| 60 |
# Pattern: Allows letters, numbers, -, _, spaces, and dots (but no double dots or starting dots or spaces)
|
| 61 |
-
pattern="^[a-zA-Z0-9_()-][a-zA-Z0-9\s_()-]*(\.[a-zA-Z0-9\s_-]+)*$",
|
| 62 |
min_length=1,
|
| 63 |
max_length=MAX_FILE_NAME_LENGTH,
|
| 64 |
)
|
|
|
|
| 5 |
MAX_FILE_NAME_LENGTH,
|
| 6 |
MAX_ID_LENGTH,
|
| 7 |
MAX_MESSAGE_LENGTH,
|
| 8 |
+
MAX_RESPONSE_LENGTH,
|
| 9 |
)
|
| 10 |
from pydantic import BaseModel, Field, field_validator
|
| 11 |
+
from typing import Literal, Set
|
| 12 |
|
| 13 |
|
| 14 |
class IdentifierBase(BaseModel):
|
|
|
|
| 47 |
return nh3.clean(human_message)
|
| 48 |
|
| 49 |
|
| 50 |
+
class FeedbackRequest(IdentifierBase, ProfileBase):
|
| 51 |
+
message_index: int = Field(ge=0, le=10_000)
|
| 52 |
+
rating: Literal["like", "dislike", "mixed"]
|
| 53 |
+
comment: str = Field(min_length=0, max_length=MAX_COMMENT_LENGTH)
|
| 54 |
+
reply_content: str = Field(min_length=1, max_length=MAX_RESPONSE_LENGTH)
|
| 55 |
+
|
| 56 |
+
@field_validator("comment")
|
| 57 |
+
def sanitize_comment(cls, comment: str):
|
| 58 |
+
"""Remove HTML tags to prevent XSS"""
|
| 59 |
+
return nh3.clean(comment)
|
| 60 |
+
|
| 61 |
+
@field_validator("reply_content")
|
| 62 |
+
def sanitize_reply_content(cls, reply_content: str):
|
| 63 |
+
"""Remove HTML tags to prevent XSS"""
|
| 64 |
+
return nh3.clean(reply_content)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
class CommentRequest(IdentifierBase, ProfileBase):
|
| 68 |
comment: str = Field(min_length=1, max_length=MAX_COMMENT_LENGTH)
|
| 69 |
|
|
|
|
| 76 |
class DeleteFileRequest(IdentifierBase, ProfileBase):
|
| 77 |
file_name: str = Field(
|
| 78 |
# Pattern: Allows letters, numbers, -, _, spaces, and dots (but no double dots or starting dots or spaces)
|
| 79 |
+
pattern=r"^[a-zA-Z0-9_()-][a-zA-Z0-9\s_()-]*(\.[a-zA-Z0-9\s_-]+)*$",
|
| 80 |
min_length=1,
|
| 81 |
max_length=MAX_FILE_NAME_LENGTH,
|
| 82 |
)
|
classes/ocr_reader.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from typing import Optional
|
| 3 |
+
import cv2
|
| 4 |
+
import easyocr
|
| 5 |
+
import torch
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger("uvicorn")
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class OCRReader:
|
| 11 |
+
_instance: Optional["OCRReader"] = None
|
| 12 |
+
ocr_reader: easyocr.Reader
|
| 13 |
+
|
| 14 |
+
def __new__(cls):
|
| 15 |
+
if cls._instance is None:
|
| 16 |
+
logger.info("Loading the OCR model into memory...")
|
| 17 |
+
cls._instance = super(OCRReader, cls).__new__(cls)
|
| 18 |
+
cls._instance.ocr_reader = easyocr.Reader(
|
| 19 |
+
["en", "fr"], gpu=torch.cuda.is_available()
|
| 20 |
+
)
|
| 21 |
+
return cls._instance
|
| 22 |
+
|
| 23 |
+
def read_text(self, img: cv2.typing.MatLike | None):
|
| 24 |
+
res = self.ocr_reader.readtext(img, detail=0)
|
| 25 |
+
if not isinstance(res, list):
|
| 26 |
+
return None
|
| 27 |
+
return " ".join([str(item) for item in res])
|
classes/pii_filter.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
from typing import List, Optional
|
| 2 |
from presidio_analyzer import AnalyzerEngine, Pattern, PatternRecognizer
|
| 3 |
from presidio_analyzer.nlp_engine import NlpEngineProvider
|
|
@@ -5,23 +6,38 @@ from presidio_anonymizer import AnonymizerEngine
|
|
| 5 |
from presidio_anonymizer.entities import OperatorConfig
|
| 6 |
|
| 7 |
# from lingua import Language, LanguageDetector
|
|
|
|
| 8 |
|
| 9 |
|
| 10 |
def create_ssn_pattern_recognizer():
|
| 11 |
# matches 111-111-111, 111 111 111, and 111111111
|
| 12 |
ssn_pattern = Pattern(
|
| 13 |
-
name="ssn_pattern", regex=r"\b\d{3}[- ]?\d{3}[- ]?\d{3}\b", score=0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
)
|
| 15 |
-
return PatternRecognizer(supported_entity="SSN", patterns=[ssn_pattern])
|
| 16 |
|
| 17 |
|
| 18 |
def create_zip_code_pattern_recognizer():
|
| 19 |
zip_code_pattern = Pattern(
|
| 20 |
name="zip_code_pattern",
|
| 21 |
regex=r"\b[A-Z]\d[A-Z]\s?\d[A-Z]\d\b", # Matches A1A 1A1 and A1A1A1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
score=0.8,
|
| 23 |
)
|
| 24 |
-
return PatternRecognizer(
|
|
|
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
def create_street_pattern_recognizer():
|
|
@@ -41,6 +57,34 @@ def create_street_pattern_recognizer():
|
|
| 41 |
)
|
| 42 |
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
class PIIFilter:
|
| 45 |
_instance: Optional["PIIFilter"] = None
|
| 46 |
analyzer: AnalyzerEngine
|
|
@@ -50,7 +94,7 @@ class PIIFilter:
|
|
| 50 |
|
| 51 |
def __new__(cls):
|
| 52 |
if cls._instance is None:
|
| 53 |
-
|
| 54 |
cls._instance = super(PIIFilter, cls).__new__(cls)
|
| 55 |
|
| 56 |
# Define which models to use for which language
|
|
@@ -69,10 +113,12 @@ class PIIFilter:
|
|
| 69 |
ssn_pattern_recognizer = create_ssn_pattern_recognizer()
|
| 70 |
zip_code_pattern_recognizer = create_zip_code_pattern_recognizer()
|
| 71 |
street_pattern_recognizer = create_street_pattern_recognizer()
|
|
|
|
| 72 |
|
| 73 |
cls._instance.analyzer.registry.add_recognizer(ssn_pattern_recognizer)
|
| 74 |
cls._instance.analyzer.registry.add_recognizer(zip_code_pattern_recognizer)
|
| 75 |
cls._instance.analyzer.registry.add_recognizer(street_pattern_recognizer)
|
|
|
|
| 76 |
|
| 77 |
cls._instance.anonymizer = AnonymizerEngine()
|
| 78 |
|
|
|
|
| 1 |
+
import logging
|
| 2 |
from typing import List, Optional
|
| 3 |
from presidio_analyzer import AnalyzerEngine, Pattern, PatternRecognizer
|
| 4 |
from presidio_analyzer.nlp_engine import NlpEngineProvider
|
|
|
|
| 6 |
from presidio_anonymizer.entities import OperatorConfig
|
| 7 |
|
| 8 |
# from lingua import Language, LanguageDetector
|
| 9 |
+
logger = logging.getLogger("uvicorn")
|
| 10 |
|
| 11 |
|
| 12 |
def create_ssn_pattern_recognizer():
|
| 13 |
# matches 111-111-111, 111 111 111, and 111111111
|
| 14 |
ssn_pattern = Pattern(
|
| 15 |
+
name="ssn_pattern", regex=r"\b\d{3}[- ]?\d{3}[- ]?\d{3}\b", score=0.9
|
| 16 |
+
)
|
| 17 |
+
fuzzy_sin_pattern = Pattern(
|
| 18 |
+
name="fuzzy_sin_pattern",
|
| 19 |
+
regex=r"\b[\dlIOS]{3}[- ]?[\dlIOS]{3}[- ]?[\dlIOS]{3}\b",
|
| 20 |
+
score=0.8,
|
| 21 |
+
)
|
| 22 |
+
return PatternRecognizer(
|
| 23 |
+
supported_entity="SSN", patterns=[ssn_pattern, fuzzy_sin_pattern]
|
| 24 |
)
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
def create_zip_code_pattern_recognizer():
|
| 28 |
zip_code_pattern = Pattern(
|
| 29 |
name="zip_code_pattern",
|
| 30 |
regex=r"\b[A-Z]\d[A-Z]\s?\d[A-Z]\d\b", # Matches A1A 1A1 and A1A1A1
|
| 31 |
+
score=0.9,
|
| 32 |
+
)
|
| 33 |
+
fuzzy_zip_code_pattern = Pattern(
|
| 34 |
+
name="fuzzy_zip_code_pattern",
|
| 35 |
+
regex=r"\b[A-Z][\dlIOS][A-Z]\s?[\dlIOS][A-Z][\dlIOS]\b",
|
| 36 |
score=0.8,
|
| 37 |
)
|
| 38 |
+
return PatternRecognizer(
|
| 39 |
+
supported_entity="ZIP_CODE", patterns=[zip_code_pattern, fuzzy_zip_code_pattern]
|
| 40 |
+
)
|
| 41 |
|
| 42 |
|
| 43 |
def create_street_pattern_recognizer():
|
|
|
|
| 57 |
)
|
| 58 |
|
| 59 |
|
| 60 |
+
# The default phone pattern recognizer does not catch some edge cases.
|
| 61 |
+
def create_phone_pattern_recognizer():
|
| 62 |
+
"""
|
| 63 |
+
Create a custom phone pattern recognizer to catch additional phone formats.
|
| 64 |
+
Matches various North American phone formats:
|
| 65 |
+
- 123-456-7890 (with dashes)
|
| 66 |
+
- 123 456 7890 (with spaces)
|
| 67 |
+
- (123) 456-7890 (with parentheses)
|
| 68 |
+
- (123) 456 7890 (with parentheses and spaces)
|
| 69 |
+
- +1-123-456-7890 (with country code and dashes)
|
| 70 |
+
- +1 (123) 456-7890 (with country code, parentheses, and dashes)
|
| 71 |
+
- +1 123 456 7890 (with country code and spaces)
|
| 72 |
+
"""
|
| 73 |
+
phone_pattern = Pattern(
|
| 74 |
+
name="phone_pattern",
|
| 75 |
+
regex=r"(?:\+\d{1,3}[-\s]?)?\(?(?:\d{3})\)?[-\s]?\d{3}[-\s]?\d{4}",
|
| 76 |
+
score=0.9,
|
| 77 |
+
)
|
| 78 |
+
fuzzy_phone_pattern = Pattern(
|
| 79 |
+
name="fuzzy_phone_pattern",
|
| 80 |
+
regex=r"(?:\+[\dlIOS]{1,3}[-\s]?)?\(?(?:[\dlIOS]{3})\)?[-\s]?[\dlIOS]{3}[-\s]?[\dlIOS]{4}",
|
| 81 |
+
score=0.8,
|
| 82 |
+
)
|
| 83 |
+
return PatternRecognizer(
|
| 84 |
+
supported_entity="PHONE_NUMBER", patterns=[phone_pattern, fuzzy_phone_pattern]
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
class PIIFilter:
|
| 89 |
_instance: Optional["PIIFilter"] = None
|
| 90 |
analyzer: AnalyzerEngine
|
|
|
|
| 94 |
|
| 95 |
def __new__(cls):
|
| 96 |
if cls._instance is None:
|
| 97 |
+
logger.info("Loading the prompt sanitizer into memory...")
|
| 98 |
cls._instance = super(PIIFilter, cls).__new__(cls)
|
| 99 |
|
| 100 |
# Define which models to use for which language
|
|
|
|
| 113 |
ssn_pattern_recognizer = create_ssn_pattern_recognizer()
|
| 114 |
zip_code_pattern_recognizer = create_zip_code_pattern_recognizer()
|
| 115 |
street_pattern_recognizer = create_street_pattern_recognizer()
|
| 116 |
+
phone_pattern_recognizer = create_phone_pattern_recognizer()
|
| 117 |
|
| 118 |
cls._instance.analyzer.registry.add_recognizer(ssn_pattern_recognizer)
|
| 119 |
cls._instance.analyzer.registry.add_recognizer(zip_code_pattern_recognizer)
|
| 120 |
cls._instance.analyzer.registry.add_recognizer(street_pattern_recognizer)
|
| 121 |
+
cls._instance.analyzer.registry.add_recognizer(phone_pattern_recognizer)
|
| 122 |
|
| 123 |
cls._instance.anonymizer = AnonymizerEngine()
|
| 124 |
|
classes/session_conversation_store.py
CHANGED
|
@@ -15,11 +15,6 @@ class SessionConversationStore:
|
|
| 15 |
# session_id -> conversation_id -> [ChatMessage]
|
| 16 |
self.session_conversation_map: Dict[str, Dict[str, List[ChatMessage]]] = dict()
|
| 17 |
|
| 18 |
-
def get_conversation(
|
| 19 |
-
self, session_id: str, conversation_id: str
|
| 20 |
-
) -> List[ChatMessage]:
|
| 21 |
-
return self.session_conversation_map[session_id][conversation_id]
|
| 22 |
-
|
| 23 |
def add_human_message(
|
| 24 |
self,
|
| 25 |
session_id: str,
|
|
@@ -27,6 +22,7 @@ class SessionConversationStore:
|
|
| 27 |
human_message: str,
|
| 28 |
):
|
| 29 |
self.__add_message(session_id, conversation_id, human_message, role="user")
|
|
|
|
| 30 |
|
| 31 |
def add_assistant_reply(
|
| 32 |
self,
|
|
@@ -35,6 +31,7 @@ class SessionConversationStore:
|
|
| 35 |
reply: str,
|
| 36 |
):
|
| 37 |
self.__add_message(session_id, conversation_id, reply, role="assistant")
|
|
|
|
| 38 |
|
| 39 |
def delete_session_conversations(self, session_id: str):
|
| 40 |
if session_id in self.session_conversation_map:
|
|
|
|
| 15 |
# session_id -> conversation_id -> [ChatMessage]
|
| 16 |
self.session_conversation_map: Dict[str, Dict[str, List[ChatMessage]]] = dict()
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
def add_human_message(
|
| 19 |
self,
|
| 20 |
session_id: str,
|
|
|
|
| 22 |
human_message: str,
|
| 23 |
):
|
| 24 |
self.__add_message(session_id, conversation_id, human_message, role="user")
|
| 25 |
+
return self.session_conversation_map[session_id][conversation_id]
|
| 26 |
|
| 27 |
def add_assistant_reply(
|
| 28 |
self,
|
|
|
|
| 31 |
reply: str,
|
| 32 |
):
|
| 33 |
self.__add_message(session_id, conversation_id, reply, role="assistant")
|
| 34 |
+
return self.session_conversation_map[session_id][conversation_id]
|
| 35 |
|
| 36 |
def delete_session_conversations(self, session_id: str):
|
| 37 |
if session_id in self.session_conversation_map:
|
classes/session_document_store.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
-
|
| 2 |
-
from langchain_core.documents import Document
|
| 3 |
|
|
|
|
| 4 |
from constants import MAX_FILE_SIZES_PER_SESSION
|
| 5 |
|
| 6 |
|
|
@@ -10,21 +10,24 @@ class SessionDocumentStore:
|
|
| 10 |
# session_id -> {file_name -> (file_text, size_in_bytes)}
|
| 11 |
self.session_document_map: Dict[str, Dict[str, Tuple[str, int]]] = dict()
|
| 12 |
|
| 13 |
-
def create_document(
|
| 14 |
-
|
| 15 |
-
):
|
| 16 |
if session_id not in self.session_document_map:
|
|
|
|
|
|
|
| 17 |
self.session_document_map[session_id] = dict()
|
|
|
|
|
|
|
| 18 |
|
| 19 |
current_total_file_size = sum(
|
| 20 |
file_text_size[1]
|
| 21 |
for file_text_size in self.session_document_map[session_id].values()
|
| 22 |
)
|
| 23 |
|
| 24 |
-
if current_total_file_size +
|
| 25 |
return False
|
| 26 |
|
| 27 |
-
self.session_document_map[session_id][file_name] = (file_text,
|
| 28 |
return True
|
| 29 |
|
| 30 |
def get_document_contents(self, session_id: str) -> List[str] | None:
|
|
@@ -40,13 +43,6 @@ class SessionDocumentStore:
|
|
| 40 |
|
| 41 |
return document_contents
|
| 42 |
|
| 43 |
-
def get_documents(self, session_id: str) -> List[Document] | None:
|
| 44 |
-
document_contents = self.get_document_contents(session_id)
|
| 45 |
-
if document_contents is None:
|
| 46 |
-
return None
|
| 47 |
-
|
| 48 |
-
return [Document(document_text) for document_text in document_contents]
|
| 49 |
-
|
| 50 |
def delete_document(self, session_id: str, file_name: str) -> bool:
|
| 51 |
"""Deletes a document with the given name. If the session no longer has documents
|
| 52 |
after the deletion, the session is also deleted and the function returns True."""
|
|
@@ -63,7 +59,4 @@ class SessionDocumentStore:
|
|
| 63 |
return False
|
| 64 |
|
| 65 |
def delete_session_documents(self, session_id: str) -> bool:
|
| 66 |
-
|
| 67 |
-
del self.session_document_map[session_id]
|
| 68 |
-
return True
|
| 69 |
-
return False
|
|
|
|
| 1 |
+
import sys
|
|
|
|
| 2 |
|
| 3 |
+
from typing import Dict, List, Tuple
|
| 4 |
from constants import MAX_FILE_SIZES_PER_SESSION
|
| 5 |
|
| 6 |
|
|
|
|
| 10 |
# session_id -> {file_name -> (file_text, size_in_bytes)}
|
| 11 |
self.session_document_map: Dict[str, Dict[str, Tuple[str, int]]] = dict()
|
| 12 |
|
| 13 |
+
def create_document(self, session_id: str, file_text: str, file_name: str):
|
| 14 |
+
text_size = sys.getsizeof(file_text)
|
|
|
|
| 15 |
if session_id not in self.session_document_map:
|
| 16 |
+
if text_size > MAX_FILE_SIZES_PER_SESSION:
|
| 17 |
+
return False
|
| 18 |
self.session_document_map[session_id] = dict()
|
| 19 |
+
self.session_document_map[session_id][file_name] = (file_text, text_size)
|
| 20 |
+
return True
|
| 21 |
|
| 22 |
current_total_file_size = sum(
|
| 23 |
file_text_size[1]
|
| 24 |
for file_text_size in self.session_document_map[session_id].values()
|
| 25 |
)
|
| 26 |
|
| 27 |
+
if current_total_file_size + text_size > MAX_FILE_SIZES_PER_SESSION:
|
| 28 |
return False
|
| 29 |
|
| 30 |
+
self.session_document_map[session_id][file_name] = (file_text, text_size)
|
| 31 |
return True
|
| 32 |
|
| 33 |
def get_document_contents(self, session_id: str) -> List[str] | None:
|
|
|
|
| 43 |
|
| 44 |
return document_contents
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
def delete_document(self, session_id: str, file_name: str) -> bool:
|
| 47 |
"""Deletes a document with the given name. If the session no longer has documents
|
| 48 |
after the deletion, the session is also deleted and the function returns True."""
|
|
|
|
| 59 |
return False
|
| 60 |
|
| 61 |
def delete_session_documents(self, session_id: str) -> bool:
|
| 62 |
+
return self.session_document_map.pop(session_id, None) is not None
|
|
|
|
|
|
|
|
|
classes/session_tracker.py
CHANGED
|
@@ -29,7 +29,6 @@ class SessionTracker:
|
|
| 29 |
return sessions_to_delete
|
| 30 |
|
| 31 |
def delete_oldest_session(self) -> str | None:
|
| 32 |
-
print(f"active sessions: {self.session_timestamp_map.keys()}")
|
| 33 |
if len(self.session_timestamp_map) == 0:
|
| 34 |
return None
|
| 35 |
oldest_session_id = min(self.session_timestamp_map.items(), key=lambda x: x[1])[
|
|
|
|
| 29 |
return sessions_to_delete
|
| 30 |
|
| 31 |
def delete_oldest_session(self) -> str | None:
|
|
|
|
| 32 |
if len(self.session_timestamp_map) == 0:
|
| 33 |
return None
|
| 34 |
oldest_session_id = min(self.session_timestamp_map.items(), key=lambda x: x[1])[
|
conftest.py
CHANGED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# conftest.py
|
| 2 |
+
import os
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@pytest.fixture(autouse=True)
|
| 7 |
+
def aws_credentials():
|
| 8 |
+
os.environ["AWS_ACCESS_KEY"] = "testing"
|
| 9 |
+
os.environ["AWS_SECRET_ACCESS_KEY"] = "testing"
|
| 10 |
+
os.environ["AWS_REGION"] = "ca-central-1"
|
constants.py
CHANGED
|
@@ -20,21 +20,24 @@ MAX_RAM_USAGE_PERCENT = 90
|
|
| 20 |
# Max history messages to keep for context
|
| 21 |
MAX_HISTORY = 20
|
| 22 |
|
| 23 |
-
MAX_MESSAGE_LENGTH =
|
| 24 |
-
MAX_COMMENT_LENGTH =
|
|
|
|
| 25 |
MAX_ID_LENGTH = 50
|
| 26 |
MAX_FILE_NAME_LENGTH = 50
|
| 27 |
|
| 28 |
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
|
| 29 |
FILE_CHUNK_SIZE = 1024 * 1024 # 1 MB
|
| 30 |
MAX_FILE_SIZES_PER_SESSION = 30 * 1024 * 1024 # 30 MB
|
|
|
|
| 31 |
|
| 32 |
SUPPORTED_FILE_EXTENSIONS = {".txt", ".pdf", ".docx", ".jpg", ".jpeg", ".png"}
|
| 33 |
SUPPORTED_FILE_TYPES = {
|
| 34 |
"text/plain", # .txt
|
| 35 |
"application/pdf", # .pdf
|
| 36 |
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", # .docx
|
| 37 |
-
|
|
|
|
| 38 |
"image/jpeg", # .jpeg and .jpg
|
| 39 |
"image/png", # .png
|
| 40 |
}
|
|
|
|
| 20 |
# Max history messages to keep for context
|
| 21 |
MAX_HISTORY = 20
|
| 22 |
|
| 23 |
+
MAX_MESSAGE_LENGTH = 2500
|
| 24 |
+
MAX_COMMENT_LENGTH = 2500
|
| 25 |
+
MAX_RESPONSE_LENGTH = 5000
|
| 26 |
MAX_ID_LENGTH = 50
|
| 27 |
MAX_FILE_NAME_LENGTH = 50
|
| 28 |
|
| 29 |
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
|
| 30 |
FILE_CHUNK_SIZE = 1024 * 1024 # 1 MB
|
| 31 |
MAX_FILE_SIZES_PER_SESSION = 30 * 1024 * 1024 # 30 MB
|
| 32 |
+
TEXT_EXTRACTION_TIMEOUT = 10 # 10 seconds
|
| 33 |
|
| 34 |
SUPPORTED_FILE_EXTENSIONS = {".txt", ".pdf", ".docx", ".jpg", ".jpeg", ".png"}
|
| 35 |
SUPPORTED_FILE_TYPES = {
|
| 36 |
"text/plain", # .txt
|
| 37 |
"application/pdf", # .pdf
|
| 38 |
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", # .docx
|
| 39 |
+
# TODO: magic can detect docx files as zip files, but not always. Under which conditions?
|
| 40 |
+
# "application/zip",
|
| 41 |
"image/jpeg", # .jpeg and .jpg
|
| 42 |
"image/png", # .png
|
| 43 |
}
|
exceptions.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from enum import Enum
|
| 2 |
+
|
| 3 |
+
from constants import (
|
| 4 |
+
STATUS_CODE_BAD_REQUEST,
|
| 5 |
+
STATUS_CODE_CONTENT_TOO_LARGE,
|
| 6 |
+
STATUS_CODE_INTERNAL_SERVER_ERROR,
|
| 7 |
+
STATUS_CODE_LENGTH_REQUIRED,
|
| 8 |
+
STATUS_CODE_UNSUPPORTED_MEDIA_TYPE,
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class FileValidationError(Enum):
|
| 13 |
+
MISSING_SIZE = "MISSING_SIZE"
|
| 14 |
+
FILE_TOO_LARGE = "FILE_TOO_LARGE"
|
| 15 |
+
MISSING_FILE_NAME = "MISSING_FILE_NAME"
|
| 16 |
+
FILE_NAME_TOO_LARGE = "FILE_NAME_TOO_LARGE"
|
| 17 |
+
INVALID_FILE_NAME = "INVALID_FILE_NAME"
|
| 18 |
+
INVALID_MIME_TYPE = "INVALID_MIME_TYPE"
|
| 19 |
+
UNSUPPORTED_EXTENSION = "UNSUPPORTED_EXTENSION"
|
| 20 |
+
EMPTY_FILE = "EMPTY_FILE"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class FileValidationException(Exception):
|
| 24 |
+
def __init__(self, error: FileValidationError):
|
| 25 |
+
self.error = error
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
FILE_VALIDATION_ERROR_STATUS_CODES = {
|
| 29 |
+
FileValidationError.MISSING_SIZE: STATUS_CODE_LENGTH_REQUIRED,
|
| 30 |
+
FileValidationError.FILE_TOO_LARGE: STATUS_CODE_CONTENT_TOO_LARGE,
|
| 31 |
+
FileValidationError.MISSING_FILE_NAME: STATUS_CODE_BAD_REQUEST,
|
| 32 |
+
FileValidationError.FILE_NAME_TOO_LARGE: STATUS_CODE_BAD_REQUEST,
|
| 33 |
+
FileValidationError.INVALID_FILE_NAME: STATUS_CODE_BAD_REQUEST,
|
| 34 |
+
FileValidationError.INVALID_MIME_TYPE: STATUS_CODE_UNSUPPORTED_MEDIA_TYPE,
|
| 35 |
+
FileValidationError.UNSUPPORTED_EXTENSION: STATUS_CODE_UNSUPPORTED_MEDIA_TYPE,
|
| 36 |
+
FileValidationError.EMPTY_FILE: STATUS_CODE_BAD_REQUEST,
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class FileExtractionError(Enum):
|
| 41 |
+
INVALID_MIME_TYPE = "INVALID_MIME_TYPE"
|
| 42 |
+
NO_TEXT = "NO_TEXT"
|
| 43 |
+
TEXT_EXTRACTION_TIMEOUT = "TEXT_EXTRACTION_TIMEOUT"
|
| 44 |
+
UNSAFE_ZIP = "UNSAFE_ZIP"
|
| 45 |
+
FILE_TOO_LARGE = "FILE_TOO_LARGE"
|
| 46 |
+
MALFORMED_FILE = "MALFORMED_FILE"
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class FileExtractionException(Exception):
|
| 50 |
+
def __init__(self, error: FileExtractionError):
|
| 51 |
+
self.error = error
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
FILE_EXTRACTION_ERROR_STATUS_CODES = {
|
| 55 |
+
FileExtractionError.INVALID_MIME_TYPE: STATUS_CODE_UNSUPPORTED_MEDIA_TYPE,
|
| 56 |
+
FileExtractionError.NO_TEXT: STATUS_CODE_BAD_REQUEST,
|
| 57 |
+
FileExtractionError.TEXT_EXTRACTION_TIMEOUT: STATUS_CODE_INTERNAL_SERVER_ERROR,
|
| 58 |
+
FileExtractionError.UNSAFE_ZIP: STATUS_CODE_INTERNAL_SERVER_ERROR,
|
| 59 |
+
FileExtractionError.FILE_TOO_LARGE: STATUS_CODE_CONTENT_TOO_LARGE,
|
| 60 |
+
FileExtractionError.MALFORMED_FILE: STATUS_CODE_BAD_REQUEST,
|
| 61 |
+
}
|
helpers/file_helper.py
CHANGED
|
@@ -1,34 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import zipfile
|
| 2 |
|
| 3 |
import cv2
|
| 4 |
-
import
|
| 5 |
import fitz # PyMuPDF
|
| 6 |
import io
|
|
|
|
| 7 |
import numpy as np
|
| 8 |
import re
|
| 9 |
|
| 10 |
from docx import Document
|
|
|
|
|
|
|
| 11 |
from PIL import Image
|
| 12 |
|
| 13 |
-
from
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
|
| 16 |
def clean_text(raw_text: str):
|
| 17 |
-
#
|
| 18 |
-
#
|
| 19 |
-
# This handles the "spaces followed by newlines" issue
|
| 20 |
lines = [line.strip() for line in raw_text.splitlines()]
|
| 21 |
|
| 22 |
-
# 2.
|
| 23 |
-
|
|
|
|
| 24 |
|
| 25 |
-
# 3.
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
# 4. Final pass: replace any remaining double-spaces with single ones
|
| 29 |
text = re.sub(r" {2,}", " ", text)
|
| 30 |
|
| 31 |
-
return text
|
| 32 |
|
| 33 |
|
| 34 |
async def extract_text_from_pdf(binary_content: bytes):
|
|
@@ -43,8 +63,7 @@ async def extract_text_from_pdf(binary_content: bytes):
|
|
| 43 |
full_text += page.get_text()
|
| 44 |
|
| 45 |
if len(full_text.strip()) == 0:
|
| 46 |
-
|
| 47 |
-
raise ValueError()
|
| 48 |
|
| 49 |
doc.close()
|
| 50 |
return clean_text(full_text)
|
|
@@ -67,18 +86,26 @@ def safe_unzip_check(file_bytes: bytes) -> bool:
|
|
| 67 |
break
|
| 68 |
total += len(chunk)
|
| 69 |
if total > MAX_FILE_SIZE:
|
| 70 |
-
|
|
|
|
|
|
|
| 71 |
return True
|
| 72 |
except zipfile.BadZipFile:
|
| 73 |
-
|
| 74 |
|
| 75 |
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
| 77 |
# Load the binary data into a stream
|
| 78 |
stream = io.BytesIO(binary_content)
|
| 79 |
|
| 80 |
# Load the docx document
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
# Extract text from all paragraphs
|
| 84 |
paragraphs = []
|
|
@@ -91,18 +118,14 @@ async def extract_text_from_docx(binary_content: bytes):
|
|
| 91 |
|
| 92 |
|
| 93 |
def sanitize_image(binary_content: bytes):
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
def extract_text_from_img(
|
| 104 |
-
binary_content: bytes, ocr_reader: easyocr.Reader
|
| 105 |
-
) -> str | None:
|
| 106 |
# 1. Convert bytes to a numpy array
|
| 107 |
nparr = np.frombuffer(binary_content, np.uint8)
|
| 108 |
|
|
@@ -110,12 +133,7 @@ def extract_text_from_img(
|
|
| 110 |
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
| 111 |
|
| 112 |
# 3. Pass the image variable directly
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
if isinstance(res, list):
|
| 116 |
-
return " ".join([str(item) for item in res])
|
| 117 |
-
|
| 118 |
-
return None
|
| 119 |
|
| 120 |
|
| 121 |
def replace_spaces_in_filename(filename: str) -> str:
|
|
@@ -142,7 +160,7 @@ def is_valid_filename(filename: str) -> bool:
|
|
| 142 |
if not filename or len(filename) > 255:
|
| 143 |
return False
|
| 144 |
|
| 145 |
-
pattern = r"^[a-zA-Z0-9_()\-]+(\.[a-zA-Z0-9_()\-]+)
|
| 146 |
if not re.match(pattern, filename):
|
| 147 |
return False
|
| 148 |
|
|
@@ -150,3 +168,126 @@ def is_valid_filename(filename: str) -> bool:
|
|
| 150 |
return False
|
| 151 |
|
| 152 |
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
from dataclasses import dataclass
|
| 3 |
+
import os
|
| 4 |
import zipfile
|
| 5 |
|
| 6 |
import cv2
|
| 7 |
+
from fastapi import UploadFile
|
| 8 |
import fitz # PyMuPDF
|
| 9 |
import io
|
| 10 |
+
import magic
|
| 11 |
import numpy as np
|
| 12 |
import re
|
| 13 |
|
| 14 |
from docx import Document
|
| 15 |
+
from lxml.etree import XMLSyntaxError
|
| 16 |
+
import PIL
|
| 17 |
from PIL import Image
|
| 18 |
|
| 19 |
+
from classes.ocr_reader import OCRReader
|
| 20 |
+
from constants import (
|
| 21 |
+
FILE_CHUNK_SIZE,
|
| 22 |
+
MAX_FILE_NAME_LENGTH,
|
| 23 |
+
MAX_FILE_SIZE,
|
| 24 |
+
SUPPORTED_FILE_EXTENSIONS,
|
| 25 |
+
SUPPORTED_FILE_TYPES,
|
| 26 |
+
TEXT_EXTRACTION_TIMEOUT,
|
| 27 |
+
)
|
| 28 |
+
from exceptions import FileExtractionError, FileExtractionException, FileValidationError
|
| 29 |
+
from exceptions import FileValidationException
|
| 30 |
|
| 31 |
|
| 32 |
def clean_text(raw_text: str):
|
| 33 |
+
# 1. Strip whitespace from the beginning and end of every line
|
| 34 |
+
# We keep the resulting empty strings to preserve the "gap" locations
|
|
|
|
| 35 |
lines = [line.strip() for line in raw_text.splitlines()]
|
| 36 |
|
| 37 |
+
# 2. Join them back together with a single newline
|
| 38 |
+
# This turns empty lines into sequences of \n
|
| 39 |
+
text = "\n".join(lines)
|
| 40 |
|
| 41 |
+
# 3. Merge 3+ newlines into 2, and 2 newlines into 2
|
| 42 |
+
# This specifically looks for 2 or more newlines and replaces them with \n\n
|
| 43 |
+
# Hello\n\n\nWorld (3) -> Hello\n\nWorld
|
| 44 |
+
# Hello\n\nWorld (2) -> Hello\n\nWorld
|
| 45 |
+
# Hello\nWorld (1) -> Not matched, stays Hello\nWorld
|
| 46 |
+
text = re.sub(r"\n{2,}", "\n\n", text)
|
| 47 |
|
| 48 |
# 4. Final pass: replace any remaining double-spaces with single ones
|
| 49 |
text = re.sub(r" {2,}", " ", text)
|
| 50 |
|
| 51 |
+
return text.strip()
|
| 52 |
|
| 53 |
|
| 54 |
async def extract_text_from_pdf(binary_content: bytes):
|
|
|
|
| 63 |
full_text += page.get_text()
|
| 64 |
|
| 65 |
if len(full_text.strip()) == 0:
|
| 66 |
+
raise FileExtractionException(FileExtractionError.NO_TEXT)
|
|
|
|
| 67 |
|
| 68 |
doc.close()
|
| 69 |
return clean_text(full_text)
|
|
|
|
| 86 |
break
|
| 87 |
total += len(chunk)
|
| 88 |
if total > MAX_FILE_SIZE:
|
| 89 |
+
raise FileExtractionException(
|
| 90 |
+
FileExtractionError.FILE_TOO_LARGE
|
| 91 |
+
)
|
| 92 |
return True
|
| 93 |
except zipfile.BadZipFile:
|
| 94 |
+
raise FileExtractionException(FileExtractionError.UNSAFE_ZIP)
|
| 95 |
|
| 96 |
|
| 97 |
+
def extract_text_from_docx(binary_content: bytes):
|
| 98 |
+
if not safe_unzip_check(binary_content):
|
| 99 |
+
return None
|
| 100 |
+
|
| 101 |
# Load the binary data into a stream
|
| 102 |
stream = io.BytesIO(binary_content)
|
| 103 |
|
| 104 |
# Load the docx document
|
| 105 |
+
try:
|
| 106 |
+
doc = Document(stream)
|
| 107 |
+
except XMLSyntaxError:
|
| 108 |
+
raise FileExtractionException(FileExtractionError.UNSAFE_ZIP)
|
| 109 |
|
| 110 |
# Extract text from all paragraphs
|
| 111 |
paragraphs = []
|
|
|
|
| 118 |
|
| 119 |
|
| 120 |
def sanitize_image(binary_content: bytes):
|
| 121 |
+
with Image.open(io.BytesIO(binary_content)) as img:
|
| 122 |
+
img = img.convert("RGB")
|
| 123 |
+
output = io.BytesIO()
|
| 124 |
+
img.save(output, format="PNG")
|
| 125 |
+
return output.getvalue()
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def extract_text_from_img(binary_content: bytes) -> str | None:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
# 1. Convert bytes to a numpy array
|
| 130 |
nparr = np.frombuffer(binary_content, np.uint8)
|
| 131 |
|
|
|
|
| 133 |
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
| 134 |
|
| 135 |
# 3. Pass the image variable directly
|
| 136 |
+
return OCRReader().read_text(img)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
|
| 139 |
def replace_spaces_in_filename(filename: str) -> str:
|
|
|
|
| 160 |
if not filename or len(filename) > 255:
|
| 161 |
return False
|
| 162 |
|
| 163 |
+
pattern = r"^[a-zA-Z0-9_()\-]+(\.[a-zA-Z0-9_()\-]+)?$"
|
| 164 |
if not re.match(pattern, filename):
|
| 165 |
return False
|
| 166 |
|
|
|
|
| 168 |
return False
|
| 169 |
|
| 170 |
return True
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
@dataclass
|
| 174 |
+
class ValidatedFile:
|
| 175 |
+
content: bytes
|
| 176 |
+
filename: str
|
| 177 |
+
mime_type: str
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
async def validate_file(file: UploadFile) -> ValidatedFile:
|
| 181 |
+
# Preliminary checks
|
| 182 |
+
file_size = file.size
|
| 183 |
+
if file_size is None:
|
| 184 |
+
raise FileValidationException(FileValidationError.MISSING_SIZE)
|
| 185 |
+
|
| 186 |
+
if file_size > MAX_FILE_SIZE:
|
| 187 |
+
raise FileValidationException(FileValidationError.FILE_TOO_LARGE)
|
| 188 |
+
|
| 189 |
+
# Check filename and extension
|
| 190 |
+
file_name = file.filename
|
| 191 |
+
if file_name is None:
|
| 192 |
+
raise FileValidationException(FileValidationError.MISSING_FILE_NAME)
|
| 193 |
+
|
| 194 |
+
if len(file_name) > MAX_FILE_NAME_LENGTH:
|
| 195 |
+
raise FileValidationException(FileValidationError.FILE_NAME_TOO_LARGE)
|
| 196 |
+
|
| 197 |
+
file_name = replace_spaces_in_filename(file_name)
|
| 198 |
+
|
| 199 |
+
if not is_valid_filename(file_name):
|
| 200 |
+
raise FileValidationException(FileValidationError.INVALID_FILE_NAME)
|
| 201 |
+
|
| 202 |
+
_, extension = os.path.splitext(file_name)
|
| 203 |
+
if extension not in SUPPORTED_FILE_EXTENSIONS:
|
| 204 |
+
raise FileValidationException(FileValidationError.UNSUPPORTED_EXTENSION)
|
| 205 |
+
|
| 206 |
+
# Check mime type from headers
|
| 207 |
+
file_mime = file.headers.get("content-type")
|
| 208 |
+
if file_mime is None or file_mime not in SUPPORTED_FILE_TYPES:
|
| 209 |
+
raise FileValidationException(FileValidationError.INVALID_MIME_TYPE)
|
| 210 |
+
|
| 211 |
+
# Read in chunks to avoid RAM spikes
|
| 212 |
+
file_content = b""
|
| 213 |
+
actual_size = 0
|
| 214 |
+
while True:
|
| 215 |
+
chunk = await file.read(FILE_CHUNK_SIZE)
|
| 216 |
+
if not chunk:
|
| 217 |
+
break
|
| 218 |
+
actual_size += len(chunk)
|
| 219 |
+
if actual_size > MAX_FILE_SIZE:
|
| 220 |
+
raise FileValidationException(FileValidationError.FILE_TOO_LARGE)
|
| 221 |
+
file_content += chunk
|
| 222 |
+
|
| 223 |
+
if actual_size == 0:
|
| 224 |
+
raise FileValidationException(FileValidationError.EMPTY_FILE)
|
| 225 |
+
|
| 226 |
+
# Verify mime type from actual file content
|
| 227 |
+
file_mime = magic.from_buffer(file_content[:2048], mime=True)
|
| 228 |
+
if file_mime not in SUPPORTED_FILE_TYPES:
|
| 229 |
+
raise FileValidationException(FileValidationError.INVALID_MIME_TYPE)
|
| 230 |
+
|
| 231 |
+
return ValidatedFile(
|
| 232 |
+
content=file_content,
|
| 233 |
+
filename=file_name,
|
| 234 |
+
mime_type=file_mime,
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
async def extract_text_from_file(file_content: bytes, file_mime: str) -> str:
|
| 239 |
+
file_text = None
|
| 240 |
+
try:
|
| 241 |
+
if file_mime == "application/pdf":
|
| 242 |
+
file_text = await asyncio.wait_for(
|
| 243 |
+
extract_text_from_pdf(file_content), timeout=TEXT_EXTRACTION_TIMEOUT
|
| 244 |
+
)
|
| 245 |
+
elif file_mime == "text/plain":
|
| 246 |
+
file_text = await asyncio.wait_for(
|
| 247 |
+
extract_text_from_txt(file_content), timeout=TEXT_EXTRACTION_TIMEOUT
|
| 248 |
+
)
|
| 249 |
+
elif (
|
| 250 |
+
file_mime
|
| 251 |
+
== "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
| 252 |
+
):
|
| 253 |
+
loop = asyncio.get_event_loop()
|
| 254 |
+
file_text = await asyncio.wait_for(
|
| 255 |
+
loop.run_in_executor(
|
| 256 |
+
None,
|
| 257 |
+
extract_text_from_docx,
|
| 258 |
+
file_content,
|
| 259 |
+
),
|
| 260 |
+
timeout=TEXT_EXTRACTION_TIMEOUT,
|
| 261 |
+
)
|
| 262 |
+
elif file_mime in ["image/jpeg", "image/png"]:
|
| 263 |
+
loop = asyncio.get_event_loop()
|
| 264 |
+
sanitized_file_content = await asyncio.wait_for(
|
| 265 |
+
loop.run_in_executor(
|
| 266 |
+
None,
|
| 267 |
+
sanitize_image,
|
| 268 |
+
file_content,
|
| 269 |
+
),
|
| 270 |
+
timeout=TEXT_EXTRACTION_TIMEOUT,
|
| 271 |
+
)
|
| 272 |
+
file_text = await asyncio.wait_for(
|
| 273 |
+
loop.run_in_executor(
|
| 274 |
+
None,
|
| 275 |
+
extract_text_from_img,
|
| 276 |
+
sanitized_file_content,
|
| 277 |
+
),
|
| 278 |
+
timeout=TEXT_EXTRACTION_TIMEOUT,
|
| 279 |
+
)
|
| 280 |
+
else:
|
| 281 |
+
raise FileExtractionException(FileExtractionError.INVALID_MIME_TYPE)
|
| 282 |
+
except asyncio.TimeoutError:
|
| 283 |
+
raise FileExtractionException(FileExtractionError.TEXT_EXTRACTION_TIMEOUT)
|
| 284 |
+
except Image.DecompressionBombError:
|
| 285 |
+
# TODO: Log the decompression bomb DOS attack
|
| 286 |
+
raise FileExtractionException(FileExtractionError.FILE_TOO_LARGE)
|
| 287 |
+
except (PIL.UnidentifiedImageError, OSError):
|
| 288 |
+
raise FileExtractionException(FileExtractionError.MALFORMED_FILE)
|
| 289 |
+
|
| 290 |
+
if file_text is None:
|
| 291 |
+
raise FileExtractionException(FileExtractionError.NO_TEXT)
|
| 292 |
+
|
| 293 |
+
return file_text
|
helpers/lifespan_helper.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import logging
|
| 3 |
+
|
| 4 |
+
import psutil
|
| 5 |
+
|
| 6 |
+
from classes.ocr_reader import OCRReader
|
| 7 |
+
from classes.pii_filter import PIIFilter
|
| 8 |
+
from classes.session_conversation_store import SessionConversationStore
|
| 9 |
+
from classes.session_document_store import SessionDocumentStore
|
| 10 |
+
from classes.session_tracker import SessionTracker
|
| 11 |
+
from constants import MAX_RAM_USAGE_PERCENT
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger("uvicorn")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def run_cleanup(
|
| 18 |
+
session_tracker: SessionTracker,
|
| 19 |
+
session_document_store: SessionDocumentStore,
|
| 20 |
+
session_conversation_store: SessionConversationStore,
|
| 21 |
+
):
|
| 22 |
+
logger.info("Running cleanup")
|
| 23 |
+
deleted_session_ids = session_tracker.delete_inactive_sessions()
|
| 24 |
+
if len(deleted_session_ids) > 0:
|
| 25 |
+
logger.info(f"{len(deleted_session_ids)} inactive sessions will be deleted.")
|
| 26 |
+
for session_id in deleted_session_ids:
|
| 27 |
+
session_document_store.delete_session_documents(session_id)
|
| 28 |
+
session_conversation_store.delete_session_conversations(session_id)
|
| 29 |
+
|
| 30 |
+
while psutil.virtual_memory().percent > MAX_RAM_USAGE_PERCENT:
|
| 31 |
+
oldest_session_id = session_tracker.delete_oldest_session()
|
| 32 |
+
logger.info(f"Deleting {oldest_session_id} session because of high RAM usage")
|
| 33 |
+
if oldest_session_id is None:
|
| 34 |
+
break
|
| 35 |
+
session_document_store.delete_session_documents(oldest_session_id)
|
| 36 |
+
session_conversation_store.delete_session_conversations(oldest_session_id)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
async def cleanup_loop(
|
| 40 |
+
session_tracker: SessionTracker,
|
| 41 |
+
session_document_store: SessionDocumentStore,
|
| 42 |
+
session_conversation_store: SessionConversationStore,
|
| 43 |
+
):
|
| 44 |
+
"""Run the 4-hour cleanup check every 10 minutes."""
|
| 45 |
+
while True:
|
| 46 |
+
await asyncio.sleep(600) # Wait 10 minutes
|
| 47 |
+
run_cleanup(session_tracker, session_document_store, session_conversation_store)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def load_heavy_models():
|
| 51 |
+
OCRReader()
|
| 52 |
+
PIIFilter()
|
helpers/llm_helper.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
from champ.rag import (
|
| 4 |
+
create_embedding_model,
|
| 5 |
+
create_session_vector_store,
|
| 6 |
+
load_vector_store,
|
| 7 |
+
)
|
| 8 |
+
from champ.service import ChampService
|
| 9 |
+
from classes.base_models import ChatMessage
|
| 10 |
+
from helpers.message_helper import convert_messages, convert_messages_langchain
|
| 11 |
+
from opentelemetry import trace
|
| 12 |
+
from google import genai
|
| 13 |
+
from openai import AsyncOpenAI
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
from typing import Any, AsyncGenerator, Dict, List, Literal, Tuple
|
| 17 |
+
|
| 18 |
+
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
| 19 |
+
if OPENAI_API_KEY is None:
|
| 20 |
+
raise RuntimeError(
|
| 21 |
+
"OPENAI_API_KEY is not set. "
|
| 22 |
+
"Go to Space → Settings → Variables & secrets and add one."
|
| 23 |
+
)
|
| 24 |
+
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
|
| 25 |
+
if GEMINI_API_KEY is None:
|
| 26 |
+
raise RuntimeError(
|
| 27 |
+
"GEMINI_API_KEY is not set. "
|
| 28 |
+
"Go to Space → Settings → Variables & secrets and add one."
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
openai_client = AsyncOpenAI(api_key=OPENAI_API_KEY) if OPENAI_API_KEY else None
|
| 32 |
+
gemini_client = genai.Client(api_key=GEMINI_API_KEY) if GEMINI_API_KEY else None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
embedding_model = create_embedding_model()
|
| 36 |
+
base_vector_store = load_vector_store(embedding_model)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# The "Google" models are differentiated by their temperature.
|
| 40 |
+
MODEL_MAP = {
|
| 41 |
+
"champ": "champ-model/placeholder",
|
| 42 |
+
"openai": "gpt-5-mini-2025-08-07",
|
| 43 |
+
"google-conservative": "gemini-2.5-flash-lite",
|
| 44 |
+
"google-creative": "gemini-2.5-flash-lite",
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
async def _call_openai(
|
| 49 |
+
model_id: str, msgs: list[dict], document_texts: List[str] | None = None
|
| 50 |
+
) -> AsyncGenerator[str, None]:
|
| 51 |
+
|
| 52 |
+
stream = await openai_client.responses.create(
|
| 53 |
+
model=model_id, input=msgs, stream=True
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
async for chunk in stream:
|
| 57 |
+
if chunk.type == "response.output_text.delta":
|
| 58 |
+
yield chunk.delta
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _call_gemini(model_id: str, msgs: list[dict], temperature: float) -> str:
|
| 62 |
+
transcript = []
|
| 63 |
+
for m in msgs:
|
| 64 |
+
role = m["role"]
|
| 65 |
+
content = m["content"]
|
| 66 |
+
transcript.append(f"{role.upper()}: {content}")
|
| 67 |
+
contents = "\n".join(transcript)
|
| 68 |
+
|
| 69 |
+
resp = gemini_client.models.generate_content(
|
| 70 |
+
model=model_id,
|
| 71 |
+
contents=contents,
|
| 72 |
+
config={"temperature": temperature},
|
| 73 |
+
)
|
| 74 |
+
return (resp.text or "").strip()
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _call_champ(
|
| 78 |
+
lang: Literal["en", "fr"],
|
| 79 |
+
conversation: List[ChatMessage],
|
| 80 |
+
document_contents: List[str] | None,
|
| 81 |
+
):
|
| 82 |
+
tracer = trace.get_tracer(__name__)
|
| 83 |
+
|
| 84 |
+
if document_contents is None:
|
| 85 |
+
vector_store = base_vector_store
|
| 86 |
+
else:
|
| 87 |
+
vector_store = create_session_vector_store(
|
| 88 |
+
base_vector_store, embedding_model, document_contents
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
with tracer.start_as_current_span("ChampService"):
|
| 92 |
+
champ = ChampService(vector_store=vector_store, lang=lang)
|
| 93 |
+
|
| 94 |
+
with tracer.start_as_current_span("convert_messages_langchain"):
|
| 95 |
+
msgs = convert_messages_langchain(conversation)
|
| 96 |
+
|
| 97 |
+
with tracer.start_as_current_span("invoke"):
|
| 98 |
+
reply, triage_meta, context = champ.invoke(msgs)
|
| 99 |
+
|
| 100 |
+
return reply, triage_meta, context
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def call_llm(
|
| 104 |
+
model_type: str,
|
| 105 |
+
lang: Literal["en", "fr"],
|
| 106 |
+
conversation: List[ChatMessage],
|
| 107 |
+
document_contents: List[str] | None,
|
| 108 |
+
) -> AsyncGenerator[str, None] | Tuple[str, Dict[str, Any], List[str]]:
|
| 109 |
+
|
| 110 |
+
if model_type not in MODEL_MAP:
|
| 111 |
+
raise ValueError(f"Unknown model_type: {model_type}")
|
| 112 |
+
|
| 113 |
+
if model_type == "champ":
|
| 114 |
+
return _call_champ(lang, conversation, document_contents)
|
| 115 |
+
|
| 116 |
+
model_id = MODEL_MAP[model_type]
|
| 117 |
+
msgs = convert_messages(conversation, lang=lang, docs_content=document_contents)
|
| 118 |
+
|
| 119 |
+
if model_type == "openai":
|
| 120 |
+
return _call_openai(model_id, msgs)
|
| 121 |
+
|
| 122 |
+
if model_type == "google-conservative":
|
| 123 |
+
return _call_gemini(model_id, msgs, temperature=0.2), {}, []
|
| 124 |
+
|
| 125 |
+
if model_type == "google-creative":
|
| 126 |
+
return _call_gemini(model_id, msgs, temperature=1.0), {}, []
|
| 127 |
+
|
| 128 |
+
# If you later add HF models via hf_client, handle here.
|
| 129 |
+
raise ValueError(f"Unhandled model_type: {model_type}")
|
helpers/message_helper.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from champ.prompts import (
|
| 2 |
+
DEFAULT_SYSTEM_PROMPT_V3,
|
| 3 |
+
DEFAULT_SYSTEM_PROMPT_WITH_CONTEXT_V3,
|
| 4 |
+
)
|
| 5 |
+
from classes.base_models import ChatMessage
|
| 6 |
+
from constants import MAX_HISTORY
|
| 7 |
+
|
| 8 |
+
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
| 9 |
+
from typing import List, Literal
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def convert_messages(
|
| 13 |
+
messages: List[ChatMessage],
|
| 14 |
+
lang: Literal["en", "fr"],
|
| 15 |
+
docs_content: List[str] | None = None,
|
| 16 |
+
):
|
| 17 |
+
"""
|
| 18 |
+
Convert our internal message format into OpenAI-style messages.
|
| 19 |
+
"""
|
| 20 |
+
# Ideally, the document contents should be aggregated in a vector store
|
| 21 |
+
# and sent to the API instead of being added manually to the system
|
| 22 |
+
# prompt. However, this would require managing uploaded files which
|
| 23 |
+
# is out of scope for the demo.
|
| 24 |
+
#
|
| 25 |
+
# Read more here: https://developers.openai.com/api/docs/guides/tools-file-search
|
| 26 |
+
language = "English" if lang == "en" else "French"
|
| 27 |
+
|
| 28 |
+
system_prompt = (
|
| 29 |
+
DEFAULT_SYSTEM_PROMPT_V3.format(language=language)
|
| 30 |
+
if docs_content is None
|
| 31 |
+
else DEFAULT_SYSTEM_PROMPT_WITH_CONTEXT_V3.format(
|
| 32 |
+
context=docs_content, language=language
|
| 33 |
+
)
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
out = [{"role": "system", "content": system_prompt}]
|
| 37 |
+
for m in messages:
|
| 38 |
+
if m.role == "system":
|
| 39 |
+
continue
|
| 40 |
+
out.append({"role": m.role, "content": m.content})
|
| 41 |
+
return out
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def convert_messages_langchain(messages: List[ChatMessage]):
|
| 45 |
+
list_chatmessages = []
|
| 46 |
+
|
| 47 |
+
for m in messages[-MAX_HISTORY:]:
|
| 48 |
+
if m.role == "user":
|
| 49 |
+
list_chatmessages.append(HumanMessage(content=m.content))
|
| 50 |
+
elif m.role == "assistant":
|
| 51 |
+
list_chatmessages.append(AIMessage(content=m.content))
|
| 52 |
+
elif m.role == "system":
|
| 53 |
+
list_chatmessages.append(SystemMessage(content=m.content))
|
| 54 |
+
return list_chatmessages
|
main.py
CHANGED
|
@@ -1,306 +1,99 @@
|
|
| 1 |
-
import os
|
| 2 |
import asyncio
|
| 3 |
-
import
|
| 4 |
-
import
|
| 5 |
-
import psutil
|
| 6 |
-
import torch
|
| 7 |
-
|
| 8 |
from contextlib import asynccontextmanager
|
|
|
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
| 12 |
from dotenv import load_dotenv
|
| 13 |
-
|
| 14 |
-
from fastapi import
|
| 15 |
-
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
| 16 |
from fastapi.staticfiles import StaticFiles
|
| 17 |
from fastapi.templating import Jinja2Templates
|
| 18 |
-
|
| 19 |
from slowapi import Limiter
|
| 20 |
from slowapi.util import get_remote_address
|
|
|
|
| 21 |
|
| 22 |
-
from opentelemetry import trace
|
| 23 |
-
|
| 24 |
-
from champ.rag import (
|
| 25 |
-
create_embedding_model,
|
| 26 |
-
create_session_vector_store,
|
| 27 |
-
load_vector_store,
|
| 28 |
-
)
|
| 29 |
from classes.base_models import (
|
| 30 |
-
ChatMessage,
|
| 31 |
ChatRequest,
|
| 32 |
CommentRequest,
|
| 33 |
DeleteFileRequest,
|
|
|
|
| 34 |
)
|
| 35 |
|
| 36 |
-
# from classes.guardrail_manager import GuardrailManager
|
| 37 |
from classes.pii_filter import PIIFilter
|
| 38 |
-
from classes.prompt_injection_filter import PromptInjectionFilter
|
| 39 |
from classes.session_conversation_store import SessionConversationStore
|
|
|
|
| 40 |
from classes.session_tracker import SessionTracker
|
| 41 |
from constants import (
|
| 42 |
-
FILE_CHUNK_SIZE,
|
| 43 |
-
MAX_FILE_NAME_LENGTH,
|
| 44 |
-
MAX_FILE_SIZE,
|
| 45 |
-
MAX_HISTORY,
|
| 46 |
MAX_ID_LENGTH,
|
| 47 |
-
MAX_RAM_USAGE_PERCENT,
|
| 48 |
-
STATUS_CODE_BAD_REQUEST,
|
| 49 |
-
STATUS_CODE_CONTENT_TOO_LARGE,
|
| 50 |
STATUS_CODE_EXCEED_SIZE_LIMIT,
|
| 51 |
STATUS_CODE_INTERNAL_SERVER_ERROR,
|
| 52 |
-
STATUS_CODE_LENGTH_REQUIRED,
|
| 53 |
-
STATUS_CODE_UNPROCESSABLE_CONTENT,
|
| 54 |
-
STATUS_CODE_UNSUPPORTED_MEDIA_TYPE,
|
| 55 |
-
SUPPORTED_FILE_EXTENSIONS,
|
| 56 |
-
SUPPORTED_FILE_TYPES,
|
| 57 |
)
|
| 58 |
-
from
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
|
| 65 |
-
|
| 66 |
-
# from lingua import Language, LanguageDetectorBuilder
|
| 67 |
-
|
| 68 |
-
from champ.prompts import (
|
| 69 |
-
DEFAULT_SYSTEM_PROMPT_V2,
|
| 70 |
-
DEFAULT_SYSTEM_PROMPT_WITH_CONTEXT_V2,
|
| 71 |
)
|
| 72 |
-
from
|
| 73 |
-
|
| 74 |
from helpers.file_helper import (
|
| 75 |
-
|
| 76 |
-
extract_text_from_img,
|
| 77 |
-
extract_text_from_pdf,
|
| 78 |
-
extract_text_from_txt,
|
| 79 |
-
is_valid_filename,
|
| 80 |
replace_spaces_in_filename,
|
| 81 |
-
|
| 82 |
-
sanitize_image,
|
| 83 |
)
|
| 84 |
-
from
|
|
|
|
| 85 |
from telemetry import setup_telemetry
|
| 86 |
|
| 87 |
load_dotenv()
|
| 88 |
|
|
|
|
|
|
|
| 89 |
# -------------------- Config --------------------
|
| 90 |
DEV = os.getenv("ENV", None) == "dev"
|
| 91 |
|
| 92 |
-
# The "Google" models are differentiated by their temperature.
|
| 93 |
-
MODEL_MAP = {
|
| 94 |
-
"champ": "champ-model/placeholder",
|
| 95 |
-
"openai": "gpt-5-mini-2025-08-07",
|
| 96 |
-
"google-conservative": "gemini-2.5-flash-lite",
|
| 97 |
-
"google-creative": "gemini-2.5-flash-lite",
|
| 98 |
-
}
|
| 99 |
-
|
| 100 |
-
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
| 101 |
-
if OPENAI_API_KEY is None:
|
| 102 |
-
raise RuntimeError(
|
| 103 |
-
"OPENAI_API_KEY is not set. "
|
| 104 |
-
"Go to Space → Settings → Variables & secrets and add one."
|
| 105 |
-
)
|
| 106 |
-
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
|
| 107 |
-
if GEMINI_API_KEY is None:
|
| 108 |
-
raise RuntimeError(
|
| 109 |
-
"GEMINI_API_KEY is not set. "
|
| 110 |
-
"Go to Space → Settings → Variables & secrets and add one."
|
| 111 |
-
)
|
| 112 |
-
|
| 113 |
-
openai_client = AsyncOpenAI(api_key=OPENAI_API_KEY) if OPENAI_API_KEY else None
|
| 114 |
-
gemini_client = genai.Client(api_key=GEMINI_API_KEY) if GEMINI_API_KEY else None
|
| 115 |
-
|
| 116 |
|
| 117 |
# -------------------- Helpers --------------------
|
| 118 |
-
embedding_model = create_embedding_model()
|
| 119 |
-
base_vector_store = load_vector_store(embedding_model)
|
| 120 |
|
| 121 |
# For now, conversations and uploaded documents are stored in RAM.
|
| 122 |
# This is tolerable for a demo, but we will have to switch to
|
| 123 |
# Redis (or another real-time database) at some point. We are
|
| 124 |
# currently storing sessions in what should be a stateless server.
|
| 125 |
-
session_document_store = SessionDocumentStore()
|
| 126 |
session_tracker = SessionTracker()
|
|
|
|
| 127 |
session_conversation_store = SessionConversationStore()
|
| 128 |
|
| 129 |
|
| 130 |
-
def run_cleanup():
|
| 131 |
-
print("running cleanup")
|
| 132 |
-
deleted_session_ids = session_tracker.delete_inactive_sessions()
|
| 133 |
-
if len(deleted_session_ids) > 0:
|
| 134 |
-
print(f"{len(deleted_session_ids)} inactive sessions will be deleted.")
|
| 135 |
-
for session_id in deleted_session_ids:
|
| 136 |
-
session_document_store.delete_session_documents(session_id)
|
| 137 |
-
session_conversation_store.delete_session_conversations(session_id)
|
| 138 |
-
|
| 139 |
-
while psutil.virtual_memory().percent > MAX_RAM_USAGE_PERCENT:
|
| 140 |
-
oldest_session_id = session_tracker.delete_oldest_session()
|
| 141 |
-
print(f"Deleting {oldest_session_id} session because of high RAM usage")
|
| 142 |
-
if oldest_session_id is None:
|
| 143 |
-
break
|
| 144 |
-
session_document_store.delete_session_documents(oldest_session_id)
|
| 145 |
-
session_conversation_store.delete_session_conversations(oldest_session_id)
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
async def cleanup_loop():
|
| 149 |
-
"""Run the 4-hour cleanup check every 10 minutes."""
|
| 150 |
-
while True:
|
| 151 |
-
await asyncio.sleep(600) # Wait 10 minutes
|
| 152 |
-
run_cleanup()
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
def convert_and_sanitize_messages(
|
| 156 |
-
messages: List[ChatMessage],
|
| 157 |
-
lang: Literal["en", "fr"],
|
| 158 |
-
docs_content: List[str] | None = None,
|
| 159 |
-
):
|
| 160 |
-
"""
|
| 161 |
-
Convert our internal message format into OpenAI-style messages.
|
| 162 |
-
"""
|
| 163 |
-
# Ideally, the document contents should be aggregated in a vector store
|
| 164 |
-
# and sent to the API instead of being added manually to the system
|
| 165 |
-
# prompt. However, this would require managing uploaded files which
|
| 166 |
-
# is out of scope for the demo.
|
| 167 |
-
#
|
| 168 |
-
# Read more here: https://developers.openai.com/api/docs/guides/tools-file-search
|
| 169 |
-
language = "English" if lang == "en" else "French"
|
| 170 |
-
|
| 171 |
-
system_prompt = (
|
| 172 |
-
DEFAULT_SYSTEM_PROMPT_V2.format(language=language)
|
| 173 |
-
if docs_content is None
|
| 174 |
-
else DEFAULT_SYSTEM_PROMPT_WITH_CONTEXT_V2.format(
|
| 175 |
-
context=docs_content, language=language
|
| 176 |
-
)
|
| 177 |
-
)
|
| 178 |
-
|
| 179 |
-
out = [{"role": "system", "content": system_prompt}]
|
| 180 |
-
for m in messages:
|
| 181 |
-
if m.role == "system":
|
| 182 |
-
continue
|
| 183 |
-
out.append({"role": m.role, "content": m.content})
|
| 184 |
-
return out
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
def convert_and_sanitize_messages_langchain(messages: List[ChatMessage]):
|
| 188 |
-
list_chatmessages = []
|
| 189 |
-
|
| 190 |
-
for m in messages[-MAX_HISTORY:]:
|
| 191 |
-
if m.role == "user":
|
| 192 |
-
list_chatmessages.append(HumanMessage(content=m.content))
|
| 193 |
-
elif m.role == "assistant":
|
| 194 |
-
list_chatmessages.append(AIMessage(content=m.content))
|
| 195 |
-
elif m.role == "system":
|
| 196 |
-
list_chatmessages.append(SystemMessage(content=m.content))
|
| 197 |
-
return list_chatmessages
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
async def _call_openai(
|
| 201 |
-
model_id: str, msgs: list[dict], document_texts: List[str] | None = None
|
| 202 |
-
) -> AsyncGenerator[str, None]:
|
| 203 |
-
|
| 204 |
-
stream = await openai_client.responses.create(
|
| 205 |
-
model=model_id, input=msgs, stream=True
|
| 206 |
-
)
|
| 207 |
-
|
| 208 |
-
async for chunk in stream:
|
| 209 |
-
if chunk.type == "response.output_text.delta":
|
| 210 |
-
yield chunk.delta
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
def _call_gemini(model_id: str, msgs: list[dict], temperature: float) -> str:
|
| 214 |
-
transcript = []
|
| 215 |
-
for m in msgs:
|
| 216 |
-
role = m["role"]
|
| 217 |
-
content = m["content"]
|
| 218 |
-
transcript.append(f"{role.upper()}: {content}")
|
| 219 |
-
contents = "\n".join(transcript)
|
| 220 |
-
|
| 221 |
-
resp = gemini_client.models.generate_content(
|
| 222 |
-
model=model_id,
|
| 223 |
-
contents=contents,
|
| 224 |
-
config={"temperature": temperature},
|
| 225 |
-
)
|
| 226 |
-
return (resp.text or "").strip()
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
def call_llm(
|
| 230 |
-
session_id: str,
|
| 231 |
-
model_type: str,
|
| 232 |
-
lang: Literal["en", "fr"],
|
| 233 |
-
conversation: List[ChatMessage],
|
| 234 |
-
) -> AsyncGenerator[str, None] | Tuple[str, Dict[str, Any], List[str]]:
|
| 235 |
-
tracer = trace.get_tracer(__name__)
|
| 236 |
-
|
| 237 |
-
if model_type == "champ":
|
| 238 |
-
session_documents = session_document_store.get_documents(session_id)
|
| 239 |
-
with tracer.start_as_current_span("vector_store"):
|
| 240 |
-
vector_store = (
|
| 241 |
-
base_vector_store
|
| 242 |
-
if session_documents is None
|
| 243 |
-
else create_session_vector_store(
|
| 244 |
-
base_vector_store, embedding_model, session_documents
|
| 245 |
-
)
|
| 246 |
-
)
|
| 247 |
-
|
| 248 |
-
with tracer.start_as_current_span("ChampService"):
|
| 249 |
-
champ = ChampService(vector_store=vector_store, lang=lang)
|
| 250 |
-
|
| 251 |
-
with tracer.start_as_current_span("convert_messages_langchain"):
|
| 252 |
-
msgs = convert_and_sanitize_messages_langchain(conversation)
|
| 253 |
-
|
| 254 |
-
with tracer.start_as_current_span("invoke"):
|
| 255 |
-
reply, triage_meta, context = champ.invoke(msgs)
|
| 256 |
-
|
| 257 |
-
return reply, triage_meta, context
|
| 258 |
-
|
| 259 |
-
if model_type not in MODEL_MAP:
|
| 260 |
-
raise ValueError(f"Unknown model_type: {model_type}")
|
| 261 |
-
|
| 262 |
-
model_id = MODEL_MAP[model_type]
|
| 263 |
-
document_contents = session_document_store.get_document_contents(session_id)
|
| 264 |
-
msgs = convert_and_sanitize_messages(
|
| 265 |
-
conversation, lang=lang, docs_content=document_contents
|
| 266 |
-
)
|
| 267 |
-
|
| 268 |
-
if model_type == "openai":
|
| 269 |
-
return _call_openai(model_id, msgs)
|
| 270 |
-
|
| 271 |
-
if model_type == "google-conservative":
|
| 272 |
-
return _call_gemini(model_id, msgs, temperature=0.2), {}, []
|
| 273 |
-
|
| 274 |
-
if model_type == "google-creative":
|
| 275 |
-
return _call_gemini(model_id, msgs, temperature=1.0), {}, []
|
| 276 |
-
|
| 277 |
-
# If you later add HF models via hf_client, handle here.
|
| 278 |
-
raise ValueError(f"Unhandled model_type: {model_type}")
|
| 279 |
-
|
| 280 |
-
|
| 281 |
# -------------------- FastAPI setup --------------------
|
| 282 |
@asynccontextmanager
|
| 283 |
async def lifespan(app: FastAPI):
|
| 284 |
-
|
| 285 |
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
|
|
|
|
|
|
| 289 |
|
| 290 |
-
|
| 291 |
-
# app.state.language_detector = LanguageDetectorBuilder.from_languages(
|
| 292 |
-
# *languages
|
| 293 |
-
# ).build()
|
| 294 |
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
|
|
|
|
|
|
|
|
|
| 298 |
|
| 299 |
-
bg_task = asyncio.create_task(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
yield
|
| 301 |
|
| 302 |
bg_task.cancel()
|
| 303 |
-
del app.state.ocr_reader
|
| 304 |
|
| 305 |
|
| 306 |
app = FastAPI(lifespan=lifespan)
|
|
@@ -312,7 +105,7 @@ templates = Jinja2Templates(directory="templates")
|
|
| 312 |
|
| 313 |
@app.middleware("http")
|
| 314 |
async def cleanup_middleware(request: Request, call_next):
|
| 315 |
-
run_cleanup()
|
| 316 |
|
| 317 |
response = await call_next(request)
|
| 318 |
return response
|
|
@@ -335,34 +128,23 @@ limiter = Limiter(key_func=get_remote_address)
|
|
| 335 |
async def chat_endpoint(
|
| 336 |
payload: ChatRequest, background_tasks: BackgroundTasks, request: Request
|
| 337 |
):
|
| 338 |
-
if not payload.human_message:
|
| 339 |
-
return JSONResponse({"error": "No message provided"}, status_code=400)
|
| 340 |
-
|
| 341 |
session_id = payload.session_id
|
| 342 |
model_type = payload.model_type
|
| 343 |
lang = payload.lang
|
| 344 |
conversation_id = payload.conversation_id
|
|
|
|
| 345 |
|
| 346 |
session_tracker.update_session(session_id)
|
| 347 |
|
| 348 |
-
prompt_injection_filter = PromptInjectionFilter()
|
| 349 |
-
injection_filtered_msg = prompt_injection_filter.sanitize_input(
|
| 350 |
-
payload.human_message
|
| 351 |
-
)
|
| 352 |
-
|
| 353 |
pii_filter = PIIFilter()
|
| 354 |
with tracer.start_as_current_span("sanitize_document"):
|
| 355 |
-
|
| 356 |
-
# injection_filtered_msg, app.state.language_detector
|
| 357 |
-
# )
|
| 358 |
-
pii_filtered_msg = pii_filter.sanitize(injection_filtered_msg)
|
| 359 |
|
| 360 |
-
session_conversation_store.add_human_message(
|
| 361 |
session_id, payload.conversation_id, pii_filtered_msg
|
| 362 |
)
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
)
|
| 366 |
|
| 367 |
reply = ""
|
| 368 |
triage_meta = {}
|
|
@@ -372,7 +154,7 @@ async def chat_endpoint(
|
|
| 372 |
loop = asyncio.get_running_loop()
|
| 373 |
with tracer.start_as_current_span("call_llm"):
|
| 374 |
result = await loop.run_in_executor(
|
| 375 |
-
None, call_llm,
|
| 376 |
)
|
| 377 |
|
| 378 |
if isinstance(result, AsyncGenerator):
|
|
@@ -434,7 +216,6 @@ async def chat_endpoint(
|
|
| 434 |
},
|
| 435 |
)
|
| 436 |
|
| 437 |
-
# Ajouter les passages récupérés
|
| 438 |
background_tasks.add_task(
|
| 439 |
log_event,
|
| 440 |
user_id=payload.user_id,
|
|
@@ -460,13 +241,37 @@ async def chat_endpoint(
|
|
| 460 |
return {"reply": reply}
|
| 461 |
|
| 462 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 463 |
@app.post("/comment")
|
| 464 |
@limiter.limit("20/minute")
|
| 465 |
def comment_endpoint(
|
| 466 |
payload: CommentRequest, background_tasks: BackgroundTasks, request: Request
|
| 467 |
):
|
| 468 |
-
|
| 469 |
-
return JSONResponse({"error": "No comment provided"}, status_code=400)
|
| 470 |
|
| 471 |
background_tasks.add_task(
|
| 472 |
log_event,
|
|
@@ -486,116 +291,42 @@ def comment_endpoint(
|
|
| 486 |
@app.put("/file")
|
| 487 |
@limiter.limit("12/minute")
|
| 488 |
async def upload_file(
|
| 489 |
-
# background_tasks: BackgroundTasks,
|
| 490 |
request: Request,
|
| 491 |
file: UploadFile = File(...),
|
| 492 |
session_id: str = Form(
|
| 493 |
pattern="^[a-zA-Z0-9_-]+$", min_length=1, max_length=MAX_ID_LENGTH
|
| 494 |
),
|
| 495 |
):
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
if file_size > MAX_FILE_SIZE:
|
| 502 |
-
return Response(status_code=STATUS_CODE_CONTENT_TOO_LARGE)
|
| 503 |
-
|
| 504 |
-
# Check filename and extensions
|
| 505 |
-
file_name = file.filename
|
| 506 |
-
if file_name is None:
|
| 507 |
-
return Response(status_code=STATUS_CODE_BAD_REQUEST)
|
| 508 |
-
|
| 509 |
-
if len(file_name) > MAX_FILE_NAME_LENGTH:
|
| 510 |
-
return Response(status_code=STATUS_CODE_UNPROCESSABLE_CONTENT)
|
| 511 |
|
| 512 |
-
|
|
|
|
|
|
|
| 513 |
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
file_mime = file.headers.get("content-type")
|
| 523 |
-
if file_mime is None:
|
| 524 |
-
print("None content-type")
|
| 525 |
-
return Response(status_code=STATUS_CODE_UNSUPPORTED_MEDIA_TYPE)
|
| 526 |
-
|
| 527 |
-
if file_mime not in SUPPORTED_FILE_TYPES:
|
| 528 |
-
print(f"Unsupported file_mime: {file_mime}")
|
| 529 |
-
return Response(status_code=STATUS_CODE_UNSUPPORTED_MEDIA_TYPE)
|
| 530 |
-
|
| 531 |
-
# Read in chunks to avoid RAM spikes
|
| 532 |
-
file_content = b""
|
| 533 |
-
file_size = 0
|
| 534 |
-
while True:
|
| 535 |
-
chunk = await file.read(FILE_CHUNK_SIZE)
|
| 536 |
-
if not chunk:
|
| 537 |
-
break
|
| 538 |
-
file_size += len(chunk)
|
| 539 |
-
if file_size > MAX_FILE_SIZE:
|
| 540 |
-
return Response(status_code=STATUS_CODE_CONTENT_TOO_LARGE)
|
| 541 |
-
file_content += chunk
|
| 542 |
-
|
| 543 |
-
file_mime = magic.from_buffer(file_content[:2048], mime=True)
|
| 544 |
-
if file_mime not in SUPPORTED_FILE_TYPES:
|
| 545 |
-
print("magic file_mime unsupported")
|
| 546 |
-
return Response(status_code=STATUS_CODE_UNSUPPORTED_MEDIA_TYPE)
|
| 547 |
-
|
| 548 |
-
if file_mime == "application/pdf":
|
| 549 |
-
file_text = await extract_text_from_pdf(file_content)
|
| 550 |
-
elif file_mime == "text/plain":
|
| 551 |
-
file_text = await extract_text_from_txt(file_content)
|
| 552 |
-
elif file_mime == "application/zip":
|
| 553 |
-
if not safe_unzip_check(file_content):
|
| 554 |
-
return Response(status_code=STATUS_CODE_CONTENT_TOO_LARGE)
|
| 555 |
-
file_text = await extract_text_from_docx(file_content)
|
| 556 |
-
elif file_mime in ["image/jpeg", "image/png"]:
|
| 557 |
-
ocr_reader = app.state.ocr_reader
|
| 558 |
-
sanitized_file_content = sanitize_image(file_content)
|
| 559 |
-
file_text = extract_text_from_img(sanitized_file_content, ocr_reader)
|
| 560 |
-
else:
|
| 561 |
-
# Theoretically impossible scenario
|
| 562 |
-
return Response(status_code=STATUS_CODE_UNSUPPORTED_MEDIA_TYPE)
|
| 563 |
-
|
| 564 |
-
if file_text is None:
|
| 565 |
return Response(status_code=STATUS_CODE_INTERNAL_SERVER_ERROR)
|
| 566 |
|
| 567 |
-
prompt_injection_filter = PromptInjectionFilter()
|
| 568 |
-
injection_filtered_file_text = prompt_injection_filter.sanitize_input(file_text)
|
| 569 |
-
|
| 570 |
pii_filter = PIIFilter()
|
| 571 |
with tracer.start_as_current_span("sanitize_document"):
|
| 572 |
-
|
| 573 |
-
# injection_filtered_file_text, app.state.language_detector
|
| 574 |
-
# )
|
| 575 |
-
pii_filtered_file_text = pii_filter.sanitize(injection_filtered_file_text)
|
| 576 |
|
| 577 |
if session_document_store.create_document(
|
| 578 |
-
session_id, pii_filtered_file_text, file_name
|
| 579 |
):
|
| 580 |
session_tracker.update_session(session_id)
|
| 581 |
else:
|
| 582 |
return Response(status_code=STATUS_CODE_EXCEED_SIZE_LIMIT)
|
| 583 |
|
| 584 |
-
# Should the logging event be coupled to the LLM call instead of the API call?
|
| 585 |
-
# background_tasks.add_task(
|
| 586 |
-
# log_event,
|
| 587 |
-
# user_id=user_id,
|
| 588 |
-
# session_id=session_id,
|
| 589 |
-
# data={
|
| 590 |
-
# "consent": consent,
|
| 591 |
-
# "age_group": age_group,
|
| 592 |
-
# "gender": gender,
|
| 593 |
-
# "roles": roles,
|
| 594 |
-
# "participant_id": participant_id,
|
| 595 |
-
# "uploaded_file_name": file_name,
|
| 596 |
-
# },
|
| 597 |
-
# )
|
| 598 |
-
|
| 599 |
|
| 600 |
@app.delete("/file")
|
| 601 |
@limiter.limit("20/minute")
|
|
@@ -608,11 +339,4 @@ def delete_file(
|
|
| 608 |
|
| 609 |
file_name = replace_spaces_in_filename(file_name)
|
| 610 |
|
| 611 |
-
if not is_valid_filename(file_name):
|
| 612 |
-
return Response(status_code=STATUS_CODE_UNPROCESSABLE_CONTENT)
|
| 613 |
-
|
| 614 |
-
_, extension = os.path.splitext(file_name)
|
| 615 |
-
if extension not in SUPPORTED_FILE_EXTENSIONS:
|
| 616 |
-
return Response(status_code=STATUS_CODE_UNSUPPORTED_MEDIA_TYPE)
|
| 617 |
-
|
| 618 |
session_document_store.delete_document(session_id, file_name)
|
|
|
|
|
|
|
| 1 |
import asyncio
|
| 2 |
+
import logging
|
| 3 |
+
import os
|
|
|
|
|
|
|
|
|
|
| 4 |
from contextlib import asynccontextmanager
|
| 5 |
+
from typing import AsyncGenerator
|
| 6 |
|
| 7 |
+
import torch
|
|
|
|
| 8 |
from dotenv import load_dotenv
|
| 9 |
+
from fastapi import BackgroundTasks, FastAPI, File, Form, Request, Response, UploadFile
|
| 10 |
+
from fastapi.responses import HTMLResponse, StreamingResponse
|
|
|
|
| 11 |
from fastapi.staticfiles import StaticFiles
|
| 12 |
from fastapi.templating import Jinja2Templates
|
| 13 |
+
from opentelemetry import trace
|
| 14 |
from slowapi import Limiter
|
| 15 |
from slowapi.util import get_remote_address
|
| 16 |
+
from uvicorn.logging import DefaultFormatter
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
from classes.base_models import (
|
|
|
|
| 19 |
ChatRequest,
|
| 20 |
CommentRequest,
|
| 21 |
DeleteFileRequest,
|
| 22 |
+
FeedbackRequest,
|
| 23 |
)
|
| 24 |
|
|
|
|
| 25 |
from classes.pii_filter import PIIFilter
|
|
|
|
| 26 |
from classes.session_conversation_store import SessionConversationStore
|
| 27 |
+
from classes.session_document_store import SessionDocumentStore
|
| 28 |
from classes.session_tracker import SessionTracker
|
| 29 |
from constants import (
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
MAX_ID_LENGTH,
|
|
|
|
|
|
|
|
|
|
| 31 |
STATUS_CODE_EXCEED_SIZE_LIMIT,
|
| 32 |
STATUS_CODE_INTERNAL_SERVER_ERROR,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
)
|
| 34 |
+
from exceptions import (
|
| 35 |
+
FILE_EXTRACTION_ERROR_STATUS_CODES,
|
| 36 |
+
FILE_VALIDATION_ERROR_STATUS_CODES,
|
| 37 |
+
FileExtractionException,
|
| 38 |
+
FileValidationException,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
)
|
| 40 |
+
from helpers.dynamodb_helper import log_event
|
|
|
|
| 41 |
from helpers.file_helper import (
|
| 42 |
+
extract_text_from_file,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
replace_spaces_in_filename,
|
| 44 |
+
validate_file,
|
|
|
|
| 45 |
)
|
| 46 |
+
from helpers.lifespan_helper import cleanup_loop, load_heavy_models, run_cleanup
|
| 47 |
+
from helpers.llm_helper import call_llm
|
| 48 |
from telemetry import setup_telemetry
|
| 49 |
|
| 50 |
load_dotenv()
|
| 51 |
|
| 52 |
+
logger = logging.getLogger("uvicorn")
|
| 53 |
+
|
| 54 |
# -------------------- Config --------------------
|
| 55 |
DEV = os.getenv("ENV", None) == "dev"
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
# -------------------- Helpers --------------------
|
|
|
|
|
|
|
| 59 |
|
| 60 |
# For now, conversations and uploaded documents are stored in RAM.
|
| 61 |
# This is tolerable for a demo, but we will have to switch to
|
| 62 |
# Redis (or another real-time database) at some point. We are
|
| 63 |
# currently storing sessions in what should be a stateless server.
|
|
|
|
| 64 |
session_tracker = SessionTracker()
|
| 65 |
+
session_document_store = SessionDocumentStore()
|
| 66 |
session_conversation_store = SessionConversationStore()
|
| 67 |
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
# -------------------- FastAPI setup --------------------
|
| 70 |
@asynccontextmanager
|
| 71 |
async def lifespan(app: FastAPI):
|
| 72 |
+
logger = logging.getLogger("uvicorn")
|
| 73 |
|
| 74 |
+
if logger.handlers:
|
| 75 |
+
colored_formatter = DefaultFormatter(
|
| 76 |
+
fmt="%(levelprefix)s %(asctime)s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
|
| 77 |
+
)
|
| 78 |
+
logger.handlers[0].setFormatter(colored_formatter)
|
| 79 |
|
| 80 |
+
logger.info("Logging configured!")
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
+
if torch.cuda.is_available():
|
| 83 |
+
logger.info("CUDA is available")
|
| 84 |
+
else:
|
| 85 |
+
logger.warning("CUDA is NOT available")
|
| 86 |
+
|
| 87 |
+
load_heavy_models()
|
| 88 |
|
| 89 |
+
bg_task = asyncio.create_task(
|
| 90 |
+
cleanup_loop(
|
| 91 |
+
session_tracker, session_document_store, session_conversation_store
|
| 92 |
+
)
|
| 93 |
+
)
|
| 94 |
yield
|
| 95 |
|
| 96 |
bg_task.cancel()
|
|
|
|
| 97 |
|
| 98 |
|
| 99 |
app = FastAPI(lifespan=lifespan)
|
|
|
|
| 105 |
|
| 106 |
@app.middleware("http")
|
| 107 |
async def cleanup_middleware(request: Request, call_next):
|
| 108 |
+
run_cleanup(session_tracker, session_document_store, session_conversation_store)
|
| 109 |
|
| 110 |
response = await call_next(request)
|
| 111 |
return response
|
|
|
|
| 128 |
async def chat_endpoint(
|
| 129 |
payload: ChatRequest, background_tasks: BackgroundTasks, request: Request
|
| 130 |
):
|
|
|
|
|
|
|
|
|
|
| 131 |
session_id = payload.session_id
|
| 132 |
model_type = payload.model_type
|
| 133 |
lang = payload.lang
|
| 134 |
conversation_id = payload.conversation_id
|
| 135 |
+
human_message = payload.human_message
|
| 136 |
|
| 137 |
session_tracker.update_session(session_id)
|
| 138 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
pii_filter = PIIFilter()
|
| 140 |
with tracer.start_as_current_span("sanitize_document"):
|
| 141 |
+
pii_filtered_msg = pii_filter.sanitize(human_message)
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
+
conversation = session_conversation_store.add_human_message(
|
| 144 |
session_id, payload.conversation_id, pii_filtered_msg
|
| 145 |
)
|
| 146 |
+
|
| 147 |
+
document_contents = session_document_store.get_document_contents(session_id)
|
|
|
|
| 148 |
|
| 149 |
reply = ""
|
| 150 |
triage_meta = {}
|
|
|
|
| 154 |
loop = asyncio.get_running_loop()
|
| 155 |
with tracer.start_as_current_span("call_llm"):
|
| 156 |
result = await loop.run_in_executor(
|
| 157 |
+
None, call_llm, model_type, lang, conversation, document_contents
|
| 158 |
)
|
| 159 |
|
| 160 |
if isinstance(result, AsyncGenerator):
|
|
|
|
| 216 |
},
|
| 217 |
)
|
| 218 |
|
|
|
|
| 219 |
background_tasks.add_task(
|
| 220 |
log_event,
|
| 221 |
user_id=payload.user_id,
|
|
|
|
| 241 |
return {"reply": reply}
|
| 242 |
|
| 243 |
|
| 244 |
+
# Endpoint for specific replies/responses
|
| 245 |
+
@app.post("/feedback")
|
| 246 |
+
@limiter.limit("20/minute")
|
| 247 |
+
def feedback_endpoint(
|
| 248 |
+
payload: FeedbackRequest, background_tasks: BackgroundTasks, request: Request
|
| 249 |
+
):
|
| 250 |
+
background_tasks.add_task(
|
| 251 |
+
log_event,
|
| 252 |
+
user_id=payload.user_id,
|
| 253 |
+
session_id=payload.session_id,
|
| 254 |
+
data={
|
| 255 |
+
"consent": payload.consent,
|
| 256 |
+
"comment": payload.comment,
|
| 257 |
+
"age_group": payload.age_group,
|
| 258 |
+
"gender": payload.gender,
|
| 259 |
+
"roles": payload.roles,
|
| 260 |
+
"participant_id": payload.participant_id,
|
| 261 |
+
"message_index": payload.message_index,
|
| 262 |
+
"rating": payload.rating,
|
| 263 |
+
"reply_content": payload.reply_content,
|
| 264 |
+
},
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
# Endpoint for specific generic comments
|
| 269 |
@app.post("/comment")
|
| 270 |
@limiter.limit("20/minute")
|
| 271 |
def comment_endpoint(
|
| 272 |
payload: CommentRequest, background_tasks: BackgroundTasks, request: Request
|
| 273 |
):
|
| 274 |
+
logger.info("Received comment")
|
|
|
|
| 275 |
|
| 276 |
background_tasks.add_task(
|
| 277 |
log_event,
|
|
|
|
| 291 |
@app.put("/file")
|
| 292 |
@limiter.limit("12/minute")
|
| 293 |
async def upload_file(
|
|
|
|
| 294 |
request: Request,
|
| 295 |
file: UploadFile = File(...),
|
| 296 |
session_id: str = Form(
|
| 297 |
pattern="^[a-zA-Z0-9_-]+$", min_length=1, max_length=MAX_ID_LENGTH
|
| 298 |
),
|
| 299 |
):
|
| 300 |
+
try:
|
| 301 |
+
validated_file = await validate_file(file)
|
| 302 |
+
except FileValidationException as e:
|
| 303 |
+
status_code = FILE_VALIDATION_ERROR_STATUS_CODES[e.error]
|
| 304 |
+
return Response(status_code=status_code)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
|
| 306 |
+
file_content = validated_file.content
|
| 307 |
+
file_name = validated_file.filename
|
| 308 |
+
file_mime = validated_file.mime_type
|
| 309 |
|
| 310 |
+
try:
|
| 311 |
+
file_text = await extract_text_from_file(file_content, file_mime)
|
| 312 |
+
except FileExtractionException as e:
|
| 313 |
+
status_code = FILE_EXTRACTION_ERROR_STATUS_CODES[e.error]
|
| 314 |
+
return Response(status_code=status_code)
|
| 315 |
+
except Exception:
|
| 316 |
+
# TODO: Log the unexpected failure
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
return Response(status_code=STATUS_CODE_INTERNAL_SERVER_ERROR)
|
| 318 |
|
|
|
|
|
|
|
|
|
|
| 319 |
pii_filter = PIIFilter()
|
| 320 |
with tracer.start_as_current_span("sanitize_document"):
|
| 321 |
+
pii_filtered_file_text = pii_filter.sanitize(file_text)
|
|
|
|
|
|
|
|
|
|
| 322 |
|
| 323 |
if session_document_store.create_document(
|
| 324 |
+
session_id, pii_filtered_file_text, file_name
|
| 325 |
):
|
| 326 |
session_tracker.update_session(session_id)
|
| 327 |
else:
|
| 328 |
return Response(status_code=STATUS_CODE_EXCEED_SIZE_LIMIT)
|
| 329 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
|
| 331 |
@app.delete("/file")
|
| 332 |
@limiter.limit("20/minute")
|
|
|
|
| 339 |
|
| 340 |
file_name = replace_spaces_in_filename(file_name)
|
| 341 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
session_document_store.delete_document(session_id, file_name)
|
pytest.ini
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# pytest.ini
|
| 2 |
+
[pytest]
|
| 3 |
+
; We skip resource_intensive tests by default
|
| 4 |
+
addopts = "-m not resource_intensive"
|
| 5 |
+
asyncio_mode = auto
|
| 6 |
+
filterwarnings =
|
| 7 |
+
ignore:builtin type SwigPyPacked has no __module__ attribute:DeprecationWarning
|
| 8 |
+
ignore:builtin type SwigPyObject has no __module__ attribute:DeprecationWarning
|
| 9 |
+
ignore:builtin type swigvarlink has no __module__ attribute:DeprecationWarning
|
| 10 |
+
ignore:The `use_auth_token` argument is deprecated and will be removed in v4 of SentenceTransformers.:FutureWarning
|
| 11 |
+
markers =
|
| 12 |
+
resource_intensive: tests that are resource intensive
|
| 13 |
+
enable_rate_limit: api tests that require enabling the request rate limit
|
| 14 |
+
flaky: tests that exhibits intermittent or sporadic failure
|
requirements-dev.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-r requirements.txt
|
| 2 |
+
pytest==9.0.2
|
| 3 |
+
pytest-asyncio==1.3.0
|
| 4 |
+
moto==5.1.21
|
| 5 |
+
botocore[crt]==1.42.34
|
| 6 |
+
coverage==7.13.4
|
| 7 |
+
fpdf2==2.8.7
|
static/app.js
CHANGED
|
@@ -1,749 +1,36 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
| 38 |
-
</svg>`;
|
| 39 |
-
|
| 40 |
-
const FILE_SIZE_LIMIT = 10 * 1024 * 1024; // 10 MB
|
| 41 |
-
const TOTAL_FILE_SIZE_LIMIT = 30 * 1024 * 1024; // 30 MB
|
| 42 |
-
const MAX_FILE_NAME_LENGTH = 50;
|
| 43 |
-
|
| 44 |
-
const statusEl = document.getElementById('status');
|
| 45 |
-
const statusComment = document.getElementById('commentStatus');
|
| 46 |
-
|
| 47 |
-
const systemPresetSelect = document.getElementById('systemPreset');
|
| 48 |
-
const clearBtn = document.getElementById('clearBtn');
|
| 49 |
-
|
| 50 |
-
const welcomePopup = document.getElementById('welcomePopup');
|
| 51 |
-
|
| 52 |
-
const consentModal = document.getElementById('consent-modal');
|
| 53 |
-
const consentCheckbox = document.getElementById('consent-checkbox');
|
| 54 |
-
const consentBtn = document.getElementById('consentBtn');
|
| 55 |
-
|
| 56 |
-
const frRadioBtn = document.getElementById('lang-fr');
|
| 57 |
-
const enRadioBtn = document.getElementById('lang-en');
|
| 58 |
-
const continueLangBtn = document.getElementById('lang-continue-btn');
|
| 59 |
-
|
| 60 |
-
const profileModal = document.getElementById('profile-modal');
|
| 61 |
-
const profileBtn = document.getElementById('profileBtn');
|
| 62 |
-
const ageGroupInput = document.getElementById('age-group');
|
| 63 |
-
const genderInput = document.getElementById('gender');
|
| 64 |
-
const roleInputs = document.querySelectorAll('input[name="role"]');
|
| 65 |
-
const participantInput = document.getElementById('participant-id');
|
| 66 |
-
|
| 67 |
-
const popupSlider = document.getElementById('mainSlider');
|
| 68 |
-
|
| 69 |
-
const leaveCommentText = document.getElementById('leave-comment');
|
| 70 |
-
const commentOverlay = document.getElementById('comment-overlay');
|
| 71 |
-
|
| 72 |
-
const closeCommentBtn = document.getElementById('closeCommentBtn');
|
| 73 |
-
const cancelCommentBtn = document.getElementById('cancelCommentBtn');
|
| 74 |
-
const sendCommentBtn = document.getElementById('sendCommentBtn');
|
| 75 |
-
const commentInput = document.getElementById('commentInput');
|
| 76 |
-
|
| 77 |
-
const increaseFontSizeBtn = document.getElementById('increase-font-size-btn');
|
| 78 |
-
const decreaseFontSizeBtn = document.getElementById('decrease-font-size-btn');
|
| 79 |
-
const resetFontSizeBtn = document.getElementById('reset-font-size-btn');
|
| 80 |
-
|
| 81 |
-
// Local in-browser chat history
|
| 82 |
-
// We store for each model its chat history and a conversation id.
|
| 83 |
-
const modelChats = {};
|
| 84 |
-
modelChats["champ"] = {"messages": [], "conversation_id": 'conversation-' + crypto.randomUUID()};
|
| 85 |
-
modelChats["openai"] = {"messages": [], "conversation_id": 'conversation-' + crypto.randomUUID()}
|
| 86 |
-
modelChats["google-conservative"] = {"messages": [], "conversation_id": 'conversation-' + crypto.randomUUID()}
|
| 87 |
-
modelChats["google-creative"] = {"messages": [], "conversation_id": 'conversation-' + crypto.randomUUID()}
|
| 88 |
-
|
| 89 |
-
let consentGranted = false;
|
| 90 |
-
|
| 91 |
-
let ageGroup = '';
|
| 92 |
-
let gender = '';
|
| 93 |
-
let roles = [];
|
| 94 |
-
let participantId = '';
|
| 95 |
-
|
| 96 |
-
let sessionId = 'session-' + crypto.randomUUID(); // Unique session ID, generated once per page load
|
| 97 |
-
document.body.classList.add('no-scroll');
|
| 98 |
-
|
| 99 |
-
let sessionFiles = [];
|
| 100 |
-
|
| 101 |
-
function openModal() {
|
| 102 |
-
// Move the translation options at the top right corner of the screen
|
| 103 |
-
langSwitchContainer.classList.add('floating');
|
| 104 |
-
}
|
| 105 |
-
|
| 106 |
-
function closeModal() {
|
| 107 |
-
// Move the translation options in the toolbar
|
| 108 |
-
langSwitchContainer.classList.remove('floating');
|
| 109 |
-
}
|
| 110 |
-
|
| 111 |
-
function renderMessages() {
|
| 112 |
-
chatWindow.innerHTML = '';
|
| 113 |
-
const modelType = systemPresetSelect.value;
|
| 114 |
-
modelChats[modelType]["messages"].forEach((m) => {
|
| 115 |
-
const bubble = document.createElement('div');
|
| 116 |
-
bubble.classList.add(
|
| 117 |
-
'msg-bubble',
|
| 118 |
-
m.role === 'user' ? 'user' : 'assistant'
|
| 119 |
-
);
|
| 120 |
-
if (m.content === "no_reply") {
|
| 121 |
-
bubble.dataset.i18n = "no_reply";
|
| 122 |
-
} else {
|
| 123 |
-
// convert markdown to HTML safely
|
| 124 |
-
bubble.innerHTML = DOMPurify.sanitize(marked.parse(m.content));
|
| 125 |
-
}
|
| 126 |
-
chatWindow.appendChild(bubble);
|
| 127 |
-
});
|
| 128 |
-
applyTranslation();
|
| 129 |
-
chatWindow.scrollTop = chatWindow.scrollHeight;
|
| 130 |
-
}
|
| 131 |
-
|
| 132 |
-
function getMachineId() {
|
| 133 |
-
let machineId = localStorage.getItem('MachineId');
|
| 134 |
-
|
| 135 |
-
if (!machineId) {
|
| 136 |
-
machineId = 'dev-' + crypto.randomUUID();
|
| 137 |
-
localStorage.setItem('MachineId', machineId);
|
| 138 |
-
}
|
| 139 |
-
|
| 140 |
-
return machineId;
|
| 141 |
-
}
|
| 142 |
-
|
| 143 |
-
// ----- Chat -----
|
| 144 |
-
|
| 145 |
-
async function sendMessage() {
|
| 146 |
-
const text = userInput.value.trim();
|
| 147 |
-
if (!text) return;
|
| 148 |
-
|
| 149 |
-
// Add user message locally
|
| 150 |
-
const modelType = systemPresetSelect.value;
|
| 151 |
-
modelChats[modelType]["messages"].push({ role: 'user', content: text });
|
| 152 |
-
renderMessages();
|
| 153 |
-
userInput.value = '';
|
| 154 |
-
|
| 155 |
-
statusEl.dataset.i18n = "thinking";
|
| 156 |
-
statusEl.className = 'status status-info';
|
| 157 |
-
applyTranslation();
|
| 158 |
-
|
| 159 |
-
const payload = {
|
| 160 |
-
user_id: getMachineId(),
|
| 161 |
-
session_id: sessionId,
|
| 162 |
-
conversation_id: modelChats[modelType]["conversation_id"],
|
| 163 |
-
human_message: text,
|
| 164 |
-
model_type: modelType,
|
| 165 |
-
consent: consentGranted,
|
| 166 |
-
age_group: ageGroup,
|
| 167 |
-
gender,
|
| 168 |
-
roles,
|
| 169 |
-
participant_id: participantId,
|
| 170 |
-
lang: currentLang
|
| 171 |
-
};
|
| 172 |
-
|
| 173 |
-
try {
|
| 174 |
-
const res = await fetch('/chat', {
|
| 175 |
-
method: 'POST',
|
| 176 |
-
headers: { 'Content-Type': 'application/json' },
|
| 177 |
-
body: JSON.stringify(payload),
|
| 178 |
-
});
|
| 179 |
-
|
| 180 |
-
if (!res.ok) {
|
| 181 |
-
statusEl.className = 'status status-error';
|
| 182 |
-
if (data.error) {
|
| 183 |
-
statusEl.textContent = data.error;
|
| 184 |
-
} else {
|
| 185 |
-
statusEl.textContent = "";
|
| 186 |
-
statusEl.dataset.i18n = "server_error";
|
| 187 |
-
applyTranslation();
|
| 188 |
-
}
|
| 189 |
-
return;
|
| 190 |
-
}
|
| 191 |
-
|
| 192 |
-
const contentType = res.headers.get('content-type');
|
| 193 |
-
|
| 194 |
-
if (contentType && contentType.includes('application/json')) {
|
| 195 |
-
// Batch response
|
| 196 |
-
const data = await res.json();
|
| 197 |
-
|
| 198 |
-
const reply = data.reply || "no_reply";
|
| 199 |
-
modelChats[modelType]["messages"].push({ role: 'assistant', content: reply });
|
| 200 |
-
renderMessages();
|
| 201 |
-
} else {
|
| 202 |
-
// Streaming response
|
| 203 |
-
const assistantMessage = { role: 'assistant', content: '' };
|
| 204 |
-
modelChats[modelType]["messages"].push(assistantMessage);
|
| 205 |
-
|
| 206 |
-
const reader = res.body.getReader();
|
| 207 |
-
const decoder = new TextDecoder();
|
| 208 |
-
let done = false;
|
| 209 |
-
|
| 210 |
-
while (!done) {
|
| 211 |
-
const { value, done: readerDone } = await reader.read();
|
| 212 |
-
done = readerDone;
|
| 213 |
-
const chunk = decoder.decode(value, { stream: true });
|
| 214 |
-
assistantMessage.content += chunk;
|
| 215 |
-
renderMessages();
|
| 216 |
-
}
|
| 217 |
-
}
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
statusEl.dataset.i18n = "ready"
|
| 221 |
-
statusEl.className = 'status status-ok';
|
| 222 |
-
applyTranslation();
|
| 223 |
-
} catch (err) {
|
| 224 |
-
statusEl.dataset.i18n = "network_error";
|
| 225 |
-
statusEl.className = 'status status-error';
|
| 226 |
-
applyTranslation()
|
| 227 |
-
}
|
| 228 |
-
}
|
| 229 |
-
|
| 230 |
-
function clearConversation() {
|
| 231 |
-
const modelType = systemPresetSelect.value;
|
| 232 |
-
modelChats[modelType]["messages"] = [];
|
| 233 |
-
modelChats[modelType]["conversation_id"] = 'conversation-' + crypto.randomUUID();
|
| 234 |
-
|
| 235 |
-
renderMessages();
|
| 236 |
-
statusEl.dataset.i18n = "conversation_cleared";
|
| 237 |
-
statusEl.className = 'status status-ok';
|
| 238 |
-
applyTranslation();
|
| 239 |
-
}
|
| 240 |
-
|
| 241 |
-
// ----- Upload file ------
|
| 242 |
-
function openFileUploadOverlay(e) {
|
| 243 |
-
e.preventDefault();
|
| 244 |
-
// Let the stylesheet take over
|
| 245 |
-
uploadFileOverlay.style.display = '';
|
| 246 |
-
|
| 247 |
-
openModal();
|
| 248 |
-
}
|
| 249 |
-
uploadFileBtn.addEventListener('click', openFileUploadOverlay);
|
| 250 |
-
|
| 251 |
-
// Open a file dialog when the drop zone is clicked
|
| 252 |
-
fileDropZone.addEventListener('click', () => fileInput.click());
|
| 253 |
-
|
| 254 |
-
// Prevent the browser from opening a dropped file
|
| 255 |
-
['dragover', 'drop'].forEach(eventName => {
|
| 256 |
-
fileDropZone.addEventListener(eventName, (e) => e.preventDefault());
|
| 257 |
-
});
|
| 258 |
-
|
| 259 |
-
fileDropZone.addEventListener('dragover', () => {
|
| 260 |
-
fileDropZone.classList.add('active');
|
| 261 |
-
});
|
| 262 |
-
|
| 263 |
-
// File drop logic
|
| 264 |
-
fileDropZone.addEventListener('drop', (e) => {
|
| 265 |
-
fileDropZone.classList.remove('active');
|
| 266 |
-
|
| 267 |
-
const addedFiles = Array.from(e.dataTransfer.files);
|
| 268 |
-
const isProcessingSuccessful = processFiles(addedFiles);
|
| 269 |
-
if (!isProcessingSuccessful) {
|
| 270 |
-
return;
|
| 271 |
-
}
|
| 272 |
-
sessionFiles = sessionFiles.concat(addedFiles);
|
| 273 |
-
addedFiles.forEach(async (file) => {
|
| 274 |
-
file.state = 'uploading';
|
| 275 |
-
isUploadSuccessful = await uploadFile(file);
|
| 276 |
-
file.state = isUploadSuccessful ? 'uploaded' : 'ready';
|
| 277 |
-
renderFiles();
|
| 278 |
-
});
|
| 279 |
-
renderFiles();
|
| 280 |
-
});
|
| 281 |
-
|
| 282 |
-
// File browsing logic
|
| 283 |
-
fileInput.addEventListener('change', (e) => {
|
| 284 |
-
const addedFiles = Array.from(e.target.files);
|
| 285 |
-
const isProcessingSuccessful = processFiles(addedFiles);
|
| 286 |
-
if (!isProcessingSuccessful) {
|
| 287 |
-
return;
|
| 288 |
-
}
|
| 289 |
-
sessionFiles = sessionFiles.concat(addedFiles);
|
| 290 |
-
addedFiles.forEach(async (file) => {
|
| 291 |
-
file.state = 'uploading';
|
| 292 |
-
isUploadSuccessful = await uploadFile(file);
|
| 293 |
-
file.state = isUploadSuccessful ? 'uploaded' : 'ready';
|
| 294 |
-
renderFiles();
|
| 295 |
-
});
|
| 296 |
-
renderFiles();
|
| 297 |
-
});
|
| 298 |
-
|
| 299 |
-
function processFiles(newFiles) {
|
| 300 |
-
const ALLOWED_TYPES = ['.pdf', '.txt', '.docx', '.jpg', '.jpeg', '.png'];
|
| 301 |
-
|
| 302 |
-
const unallowed_files = newFiles.filter((file) => !ALLOWED_TYPES.some(ext => file.name.endsWith(ext)))
|
| 303 |
-
|
| 304 |
-
if (unallowed_files.length > 0) {
|
| 305 |
-
newFiles.forEach((file) => {
|
| 306 |
-
removeFileFromInput(fileInput, file)
|
| 307 |
-
});
|
| 308 |
-
showSnackbar(translations[currentLang]["error_file_format"], "error");
|
| 309 |
-
return false;
|
| 310 |
-
}
|
| 311 |
-
|
| 312 |
-
const large_files = newFiles.filter((file) => file.size > FILE_SIZE_LIMIT);
|
| 313 |
-
if (large_files.length > 0) {
|
| 314 |
-
newFiles.forEach((file) => {
|
| 315 |
-
removeFileFromInput(fileInput, file)
|
| 316 |
-
});
|
| 317 |
-
showSnackbar(translations[currentLang]["error_file_size"], "error");
|
| 318 |
-
return false;
|
| 319 |
-
}
|
| 320 |
-
|
| 321 |
-
const totalFileSize = [...newFiles, ...sessionFiles].reduce((sum, file) => sum + file.size, 0);
|
| 322 |
-
if (totalFileSize > TOTAL_FILE_SIZE_LIMIT) {
|
| 323 |
-
newFiles.forEach((file) => {
|
| 324 |
-
removeFileFromInput(fileInput, file)
|
| 325 |
-
});
|
| 326 |
-
showSnackbar(translations[currentLang]["error_total_file_size"], "error");
|
| 327 |
-
return false;
|
| 328 |
-
}
|
| 329 |
-
|
| 330 |
-
const files_with_long_name = newFiles.filter((file) => file.name.length > MAX_FILE_NAME_LENGTH);
|
| 331 |
-
if (files_with_long_name.length > 0) {
|
| 332 |
-
newFiles.forEach((file) => {
|
| 333 |
-
removeFileFromInput(fileInput, file)
|
| 334 |
-
});
|
| 335 |
-
showSnackbar(translations[currentLang]["error_file_name_length"], "error");
|
| 336 |
-
return false;
|
| 337 |
-
}
|
| 338 |
-
|
| 339 |
-
return true;
|
| 340 |
-
};
|
| 341 |
-
|
| 342 |
-
function removeFileFromInput(fileInput, fileToRemove) {
|
| 343 |
-
// File inputs are read-only. We have to update them
|
| 344 |
-
// by assigning a new value instead of filtering out
|
| 345 |
-
// directly files we do not want anymore.
|
| 346 |
-
const dt = new DataTransfer();
|
| 347 |
-
const { files } = fileInput;
|
| 348 |
-
|
| 349 |
-
for (let i = 0; i < files.length; i++) {
|
| 350 |
-
const file = files[i];
|
| 351 |
-
if (file !== fileToRemove) {
|
| 352 |
-
dt.items.add(file);
|
| 353 |
-
}
|
| 354 |
-
}
|
| 355 |
-
|
| 356 |
-
fileInput.files = dt.files;
|
| 357 |
-
}
|
| 358 |
-
|
| 359 |
-
function renderFiles() {
|
| 360 |
-
fileListHtml.innerHTML = '';
|
| 361 |
-
|
| 362 |
-
if (sessionFiles.length === 0) {
|
| 363 |
-
const noFileMessage = document.createElement('div');
|
| 364 |
-
noFileMessage.classList.add('no-file');
|
| 365 |
-
noFileMessage.dataset.i18n = "no_files";
|
| 366 |
-
fileListHtml.appendChild(noFileMessage);
|
| 367 |
-
applyTranslation();
|
| 368 |
-
return;
|
| 369 |
-
}
|
| 370 |
-
|
| 371 |
-
sessionFiles.forEach((f) => {
|
| 372 |
-
const fileItem = document.createElement('div');
|
| 373 |
-
fileItem.classList.add('file-item');
|
| 374 |
-
|
| 375 |
-
fileItem.textContent = f.name;
|
| 376 |
-
|
| 377 |
-
const fileActions = document.createElement('div');
|
| 378 |
-
fileActions.classList.add('file-actions');
|
| 379 |
-
|
| 380 |
-
const uploadButton = document.createElement('button');
|
| 381 |
-
if (f.state === 'uploaded') {
|
| 382 |
-
uploadButton.innerHTML = HTML_CHECK_ICON + `<span data-i18n="file_uploaded"></span>`;
|
| 383 |
-
uploadButton.classList.add('disabled-button');
|
| 384 |
-
uploadButton.disabled = true;
|
| 385 |
-
} else if (f.state === 'uploading') {
|
| 386 |
-
uploadButton.innerHTML = HTML_SPINNER_ICON + `<span data-i18n="file_uploading"></span>`;
|
| 387 |
-
uploadButton.classList.add('disabled-button');
|
| 388 |
-
uploadButton.disabled = true;
|
| 389 |
-
} else if (f.state == 'ready') {
|
| 390 |
-
uploadButton.innerHTML = HTML_UPLOAD_ICON + `<span data-i18n="file_upload"></span>`;
|
| 391 |
-
uploadButton.classList.add('ok-button');
|
| 392 |
-
uploadButton.addEventListener('click', async () => {
|
| 393 |
-
f.state = 'uploading';
|
| 394 |
-
renderFiles();
|
| 395 |
-
isUploadSuccessful = await uploadFile(f);
|
| 396 |
-
f.state = isUploadSuccessful ? 'uploaded' : 'ready';
|
| 397 |
-
renderFiles();
|
| 398 |
-
});
|
| 399 |
-
}
|
| 400 |
-
|
| 401 |
-
const deleteButton = document.createElement('button');
|
| 402 |
-
deleteButton.innerHTML = HTML_TRASH_ICON + `<span data-i18n="file_delete"></span>`;
|
| 403 |
-
deleteButton.classList.add('no-button');
|
| 404 |
-
deleteButton.addEventListener('click', async () => {
|
| 405 |
-
// No need to send a request to the server if the file was not uploaded
|
| 406 |
-
isDeletionSuccessful = f.state === 'uploaded' ? await deleteFile(f) : true;
|
| 407 |
-
if (isDeletionSuccessful) {
|
| 408 |
-
removeFileFromInput(fileInput, f);
|
| 409 |
-
sessionFiles = sessionFiles.filter((file) => file !== f);
|
| 410 |
-
renderFiles();
|
| 411 |
-
}
|
| 412 |
-
});
|
| 413 |
-
|
| 414 |
-
fileActions.appendChild(uploadButton);
|
| 415 |
-
fileActions.appendChild(deleteButton);
|
| 416 |
-
fileItem.appendChild(fileActions);
|
| 417 |
-
fileListHtml.appendChild(fileItem);
|
| 418 |
-
applyTranslation();
|
| 419 |
-
});
|
| 420 |
-
};
|
| 421 |
-
|
| 422 |
-
async function uploadFile(file) {
|
| 423 |
-
// Can't use JSON payloads to send PDF or DOCX files
|
| 424 |
-
const formData = new FormData();
|
| 425 |
-
formData.append('file', file);
|
| 426 |
-
// formData.append('user_id', getMachineId());
|
| 427 |
-
formData.append('session_id', sessionId);
|
| 428 |
-
// formData.append('consent', consentGranted);
|
| 429 |
-
// formData.append('age_group', ageGroup);
|
| 430 |
-
// formData.append('gender', gender);
|
| 431 |
-
// formData.append('roles', roles);
|
| 432 |
-
// formData.append('participant_id', participantId);
|
| 433 |
-
|
| 434 |
-
try {
|
| 435 |
-
const res = await fetch('/file', {
|
| 436 |
-
method: 'PUT',
|
| 437 |
-
body: formData,
|
| 438 |
-
});
|
| 439 |
-
|
| 440 |
-
if (!res.ok) {
|
| 441 |
-
showSnackbar(translations[currentLang]["file_upload_failed_server_error"], 'error');
|
| 442 |
-
return false;
|
| 443 |
-
}
|
| 444 |
-
|
| 445 |
-
showSnackbar(translations[currentLang]["file_upload_success"], 'success');
|
| 446 |
-
return true;
|
| 447 |
-
} catch (err) {
|
| 448 |
-
showSnackbar(translations[currentLang]["file_upload_failed_network_error"], 'error');
|
| 449 |
-
return false;
|
| 450 |
-
}
|
| 451 |
-
}
|
| 452 |
-
|
| 453 |
-
async function deleteFile(file) {
|
| 454 |
-
const payload = {
|
| 455 |
-
file_name: file.name,
|
| 456 |
-
user_id: getMachineId(),
|
| 457 |
-
session_id: sessionId,
|
| 458 |
-
consent: consentGranted,
|
| 459 |
-
age_group: ageGroup,
|
| 460 |
-
gender,
|
| 461 |
-
roles,
|
| 462 |
-
participant_id: participantId
|
| 463 |
-
};
|
| 464 |
-
|
| 465 |
-
try {
|
| 466 |
-
const res = await fetch('/file', {
|
| 467 |
-
method: 'DELETE',
|
| 468 |
-
body: JSON.stringify(payload),
|
| 469 |
-
headers: { 'Content-Type': 'application/json' },
|
| 470 |
-
});
|
| 471 |
-
|
| 472 |
-
if (!res.ok) {
|
| 473 |
-
showSnackbar(translations[currentLang]["file_upload_failed_server_error"], 'error');
|
| 474 |
-
return false;
|
| 475 |
-
}
|
| 476 |
-
|
| 477 |
-
showSnackbar(translations[currentLang]["file_delete_success"], 'success');
|
| 478 |
-
return true;
|
| 479 |
-
} catch (err) {
|
| 480 |
-
showSnackbar(translations[currentLang]["file_delete_failed_network_error"], 'error');
|
| 481 |
-
return false;
|
| 482 |
-
}
|
| 483 |
-
}
|
| 484 |
-
|
| 485 |
-
// Close the overlay
|
| 486 |
-
closeFileUploadBtn.addEventListener('click', () => {
|
| 487 |
-
uploadFileOverlay.style.display = 'none';
|
| 488 |
-
closeModal();
|
| 489 |
-
});
|
| 490 |
-
doneFileUploadBtn.addEventListener('click', () => {
|
| 491 |
-
uploadFileOverlay.style.display = 'none';
|
| 492 |
-
closeModal();
|
| 493 |
-
})
|
| 494 |
-
|
| 495 |
-
// ----- Event wiring -----
|
| 496 |
-
|
| 497 |
-
// Language modal logic
|
| 498 |
-
continueLangBtn.addEventListener('click', () => {
|
| 499 |
-
consentModal.scrollIntoView({
|
| 500 |
-
behavior: 'smooth',
|
| 501 |
-
inline: 'start',
|
| 502 |
-
block: 'nearest'
|
| 503 |
-
});
|
| 504 |
-
});
|
| 505 |
-
|
| 506 |
-
frRadioBtn.addEventListener('change', () => {
|
| 507 |
-
currentLang = frRadioBtn.value;
|
| 508 |
-
setLanguage();
|
| 509 |
-
});
|
| 510 |
-
enRadioBtn.addEventListener('change', () => {
|
| 511 |
-
currentLang = enRadioBtn.value;
|
| 512 |
-
setLanguage();
|
| 513 |
-
});
|
| 514 |
-
|
| 515 |
-
// Consent logic
|
| 516 |
-
// When the checkbox is toggled, enable or disable the button
|
| 517 |
-
consentCheckbox.addEventListener('change', () => {
|
| 518 |
-
if (consentCheckbox.checked) {
|
| 519 |
-
consentBtn.disabled = false;
|
| 520 |
-
consentBtn.classList.replace('disabled-button', 'ok-button')
|
| 521 |
-
} else {
|
| 522 |
-
consentBtn.disabled = true;
|
| 523 |
-
consentBtn.classList.replace('ok-button', 'disabled-button')
|
| 524 |
-
}
|
| 525 |
-
});
|
| 526 |
-
|
| 527 |
-
// Handle the consent acceptance
|
| 528 |
-
consentBtn.addEventListener('click', () => {
|
| 529 |
-
consentGranted = true; // Mark consent as granted
|
| 530 |
-
profileModal.scrollIntoView({
|
| 531 |
-
behavior: 'smooth',
|
| 532 |
-
inline: 'start',
|
| 533 |
-
block: 'nearest'
|
| 534 |
-
});
|
| 535 |
-
});
|
| 536 |
-
|
| 537 |
-
// When the profile is changed, enable or disable the button
|
| 538 |
-
function checkProfileValidity () {
|
| 539 |
-
// 1. Check if any gender is selected
|
| 540 |
-
const genderSelected = genderInput.value !== '';
|
| 541 |
-
|
| 542 |
-
// 2. Check if any age group is selected
|
| 543 |
-
const ageSelected = ageGroupInput.value !== '';
|
| 544 |
-
|
| 545 |
-
// 3. Check if at least one role checkbox is selected
|
| 546 |
-
const roleSelected = Array.from(roleInputs).some(input => input.checked);
|
| 547 |
-
|
| 548 |
-
// 4. Check if the participant id field has a value
|
| 549 |
-
const participantIdEntered = participantInput.value.trim().length > 0;
|
| 550 |
-
|
| 551 |
-
// 5. Enable button only if both are true
|
| 552 |
-
if (genderSelected && ageSelected && roleSelected && participantIdEntered) {
|
| 553 |
-
profileBtn.disabled = false;
|
| 554 |
-
profileBtn.classList.replace('disabled-button', 'ok-button')
|
| 555 |
-
} else {
|
| 556 |
-
profileBtn.disabled = true;
|
| 557 |
-
profileBtn.classList.replace('ok-button', 'disabled-button');
|
| 558 |
-
}
|
| 559 |
-
}
|
| 560 |
-
// Add the listener to all gender radio buttons and role checkboxes
|
| 561 |
-
genderInput.addEventListener('click', checkProfileValidity);
|
| 562 |
-
ageGroupInput.addEventListener('click', checkProfileValidity);
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
roleInputs.forEach(input => input.addEventListener('change', checkProfileValidity));
|
| 566 |
-
participantInput.addEventListener('input', checkProfileValidity);
|
| 567 |
-
|
| 568 |
-
profileBtn.addEventListener('click', () => {
|
| 569 |
-
welcomePopup.style.display = 'none'; // Hide overlay
|
| 570 |
-
document.body.classList.remove('no-scroll'); // NEW: re-enable scrolling
|
| 571 |
-
|
| 572 |
-
ageGroup = document.getElementById('age-group').value;
|
| 573 |
-
gender = document.getElementById('gender').value;
|
| 574 |
-
roles = Array.from(document.querySelectorAll('input[name="role"]:checked')).map(input => input.value);
|
| 575 |
-
participantId = participantInput.value.trim();
|
| 576 |
-
|
| 577 |
-
closeModal();
|
| 578 |
-
});
|
| 579 |
-
|
| 580 |
-
sendBtn.addEventListener('click', sendMessage);
|
| 581 |
-
|
| 582 |
-
// Enter to send, Shift+Enter = newline
|
| 583 |
-
userInput.addEventListener('keydown', (e) => {
|
| 584 |
-
if (e.key === 'Enter' && !e.shiftKey) {
|
| 585 |
-
e.preventDefault();
|
| 586 |
-
sendMessage();
|
| 587 |
-
}
|
| 588 |
-
});
|
| 589 |
-
commentInput.addEventListener('keydown', (e) => {
|
| 590 |
-
if (e.key === 'Enter' && !e.shiftKey) {
|
| 591 |
-
e.preventDefault();
|
| 592 |
-
sendComment();
|
| 593 |
-
}
|
| 594 |
-
});
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
clearBtn.addEventListener('click', clearConversation);
|
| 598 |
-
|
| 599 |
-
systemPresetSelect.addEventListener('change', () => {
|
| 600 |
-
statusEl.dataset.i18n = "model_changed";
|
| 601 |
-
statusEl.className = 'status status-ok';
|
| 602 |
-
renderMessages();
|
| 603 |
-
applyTranslation();
|
| 604 |
-
});
|
| 605 |
-
|
| 606 |
-
// Comments
|
| 607 |
-
function openCommentOverlay(e) {
|
| 608 |
-
e.preventDefault();
|
| 609 |
-
// Let the stylesheet take over
|
| 610 |
-
commentOverlay.style.display = '';
|
| 611 |
-
|
| 612 |
-
openModal();
|
| 613 |
-
}
|
| 614 |
-
leaveCommentText.addEventListener('click', openCommentOverlay);
|
| 615 |
-
|
| 616 |
-
// Cancelling or closing the comment overlay simply hides the comment popup
|
| 617 |
-
closeCommentBtn.addEventListener('click', () => {
|
| 618 |
-
commentOverlay.style.display = 'none';
|
| 619 |
-
closeModal();
|
| 620 |
-
});
|
| 621 |
-
cancelCommentBtn.addEventListener('click', () => {
|
| 622 |
-
commentOverlay.style.display = 'none';
|
| 623 |
-
closeModal();
|
| 624 |
-
});
|
| 625 |
-
|
| 626 |
-
async function sendComment() {
|
| 627 |
-
const comment = commentInput.value;
|
| 628 |
-
if (!comment) return;
|
| 629 |
-
|
| 630 |
-
const payload = {
|
| 631 |
-
user_id: getMachineId(),
|
| 632 |
-
session_id: sessionId,
|
| 633 |
-
comment,
|
| 634 |
-
consent: consentGranted,
|
| 635 |
-
age_group: ageGroup,
|
| 636 |
-
gender,
|
| 637 |
-
roles,
|
| 638 |
-
participant_id: participantId
|
| 639 |
-
};
|
| 640 |
-
|
| 641 |
-
statusComment.dataset.i18n = "sending";
|
| 642 |
-
statusComment.className = 'status-info';
|
| 643 |
-
applyTranslation();
|
| 644 |
-
|
| 645 |
-
try {
|
| 646 |
-
const res = await fetch('/comment', {
|
| 647 |
-
method: 'POST',
|
| 648 |
-
headers: { 'Content-Type': 'application/json' },
|
| 649 |
-
body: JSON.stringify(payload),
|
| 650 |
-
});
|
| 651 |
-
|
| 652 |
-
if (!res.ok) {
|
| 653 |
-
statusComment.dataset.i18n = "server_error";
|
| 654 |
-
statusComment.className = 'status-error';
|
| 655 |
-
applyTranslation();
|
| 656 |
-
return;
|
| 657 |
-
}
|
| 658 |
-
|
| 659 |
-
commentInput.value = '';
|
| 660 |
-
|
| 661 |
-
statusComment.dataset.i18n = "comment_sent";
|
| 662 |
-
statusComment.className = 'status-ok';
|
| 663 |
-
applyTranslation();
|
| 664 |
-
} catch (err) {
|
| 665 |
-
statusComment.dataset.i18n = "network_error";
|
| 666 |
-
statusComment.className = 'status-error';
|
| 667 |
-
applyTranslation();
|
| 668 |
-
}
|
| 669 |
-
|
| 670 |
-
};
|
| 671 |
-
sendCommentBtn.addEventListener('click', sendComment);
|
| 672 |
-
|
| 673 |
-
// Translation
|
| 674 |
-
function setLanguage() {
|
| 675 |
-
applyTranslation();
|
| 676 |
-
|
| 677 |
-
document.getElementById('btn-en').classList.toggle('active', currentLang === 'en');
|
| 678 |
-
document.getElementById('btn-fr').classList.toggle('active', currentLang === 'fr');
|
| 679 |
-
|
| 680 |
-
frRadioBtn.checked = currentLang === 'fr';
|
| 681 |
-
enRadioBtn.checked = currentLang === 'en';
|
| 682 |
-
|
| 683 |
-
localStorage.setItem('preferredLang', currentLang);
|
| 684 |
-
};
|
| 685 |
-
|
| 686 |
-
enBtn.addEventListener('click', () => {
|
| 687 |
-
currentLang = 'en';
|
| 688 |
-
setLanguage();
|
| 689 |
-
});
|
| 690 |
-
frBtn.addEventListener('click', () => {
|
| 691 |
-
currentLang = 'fr';
|
| 692 |
-
setLanguage();
|
| 693 |
-
});
|
| 694 |
-
|
| 695 |
-
function applyTranslation() {
|
| 696 |
-
document.querySelectorAll('[data-i18n]').forEach(element => {
|
| 697 |
-
const key = element.getAttribute('data-i18n');
|
| 698 |
-
element.textContent = translations[currentLang][key];
|
| 699 |
-
});
|
| 700 |
-
userInput.placeholder = translations[currentLang]["input_placeholder"];
|
| 701 |
-
commentInput.placeholder = translations[currentLang]["comment_placeholder"];
|
| 702 |
-
};
|
| 703 |
-
|
| 704 |
-
const MIN_FONT_SIZE = 0.75;
|
| 705 |
-
const MAX_FONT_SIZE = 2.5;
|
| 706 |
-
const FONT_SIZE_STEP = 0.125; // 1/8 rem for smooth increments
|
| 707 |
-
|
| 708 |
-
let currentSize = 1; // 1rem = browser default (usually 16px)
|
| 709 |
-
|
| 710 |
-
// Font size
|
| 711 |
-
function updateFontSize(newSize) {
|
| 712 |
-
currentSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, newSize));
|
| 713 |
-
document.documentElement.style.fontSize = currentSize + 'rem';
|
| 714 |
-
}
|
| 715 |
-
|
| 716 |
-
increaseFontSizeBtn.addEventListener('click', () => {
|
| 717 |
-
updateFontSize(currentSize + FONT_SIZE_STEP);
|
| 718 |
-
});
|
| 719 |
-
|
| 720 |
-
decreaseFontSizeBtn.addEventListener('click', () => {
|
| 721 |
-
updateFontSize(currentSize - FONT_SIZE_STEP);
|
| 722 |
-
});
|
| 723 |
-
|
| 724 |
-
resetFontSizeBtn.addEventListener('click', () => {
|
| 725 |
-
updateFontSize(1); // 1rem = browser default
|
| 726 |
-
});
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
// Setup
|
| 730 |
-
statusComment.dataset.i18n = "ready";
|
| 731 |
-
statusComment.className = 'status-ok';
|
| 732 |
-
|
| 733 |
-
if (currentLang == "en") {
|
| 734 |
-
enBtn.classList.add('active');
|
| 735 |
-
enRadioBtn.checked = true;
|
| 736 |
-
} else {
|
| 737 |
-
frBtn.classList.add('active');
|
| 738 |
-
frRadioBtn.checked = true;
|
| 739 |
-
}
|
| 740 |
-
|
| 741 |
-
applyTranslation();
|
| 742 |
-
renderFiles();
|
| 743 |
-
|
| 744 |
-
// Open the details element by default on desktop only.
|
| 745 |
-
if (window.innerWidth >= 460) {
|
| 746 |
-
document.querySelector('details').setAttribute('open', '');
|
| 747 |
-
}
|
| 748 |
-
|
| 749 |
-
openModal();
|
|
|
|
| 1 |
+
// app.js - Main application initialization
|
| 2 |
+
|
| 3 |
+
import { ChatComponent } from './components/chat-component.js';
|
| 4 |
+
import { FileUploadComponent } from './components/file-upload-component.js';
|
| 5 |
+
import { SettingsComponent } from './components/settings-component.js';
|
| 6 |
+
import { LanguageComponent } from './components/language-component.js';
|
| 7 |
+
import { ConsentComponent } from './components/consent-component.js';
|
| 8 |
+
import { ProfileComponent } from './components/profile-component.js';
|
| 9 |
+
import { CommentComponent } from './components/comment-component.js';
|
| 10 |
+
import { FeedbackComponent } from './components/feedback-component.js';
|
| 11 |
+
import { TranslationService } from './services/translation-service.js';
|
| 12 |
+
|
| 13 |
+
// Initialize the application when DOM is ready
|
| 14 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 15 |
+
// Initialize all components
|
| 16 |
+
ChatComponent.init();
|
| 17 |
+
FileUploadComponent.init();
|
| 18 |
+
SettingsComponent.init();
|
| 19 |
+
LanguageComponent.init();
|
| 20 |
+
ConsentComponent.init();
|
| 21 |
+
ProfileComponent.init();
|
| 22 |
+
CommentComponent.init();
|
| 23 |
+
FeedbackComponent.init();
|
| 24 |
+
|
| 25 |
+
// Make FeedbackComponent globally accessible for chat component
|
| 26 |
+
window.FeedbackComponent = FeedbackComponent;
|
| 27 |
+
|
| 28 |
+
// Apply initial translations
|
| 29 |
+
TranslationService.applyTranslation();
|
| 30 |
+
|
| 31 |
+
// Open the details element by default on desktop only
|
| 32 |
+
if (window.innerWidth >= 460) {
|
| 33 |
+
const details = document.querySelector('details');
|
| 34 |
+
if (details) details.setAttribute('open', '');
|
| 35 |
+
}
|
| 36 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/components/chat-component.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// components/chat-component.js - Chat functionality
|
| 2 |
+
|
| 3 |
+
import { StateManager } from '../services/state-manager.js';
|
| 4 |
+
import { ApiService } from '../services/api-service.js';
|
| 5 |
+
import { TranslationService } from '../services/translation-service.js';
|
| 6 |
+
|
| 7 |
+
export const ChatComponent = {
|
| 8 |
+
elements: {
|
| 9 |
+
chatWindow: null,
|
| 10 |
+
userInput: null,
|
| 11 |
+
sendBtn: null,
|
| 12 |
+
clearBtn: null,
|
| 13 |
+
systemPresetSelect: null,
|
| 14 |
+
statusEl: null
|
| 15 |
+
},
|
| 16 |
+
|
| 17 |
+
/**
|
| 18 |
+
* Initialize the chat component
|
| 19 |
+
*/
|
| 20 |
+
init() {
|
| 21 |
+
this.elements.chatWindow = document.getElementById('chatWindow');
|
| 22 |
+
this.elements.userInput = document.getElementById('userInput');
|
| 23 |
+
this.elements.sendBtn = document.getElementById('sendBtn');
|
| 24 |
+
this.elements.clearBtn = document.getElementById('clearBtn');
|
| 25 |
+
this.elements.systemPresetSelect = document.getElementById('systemPreset');
|
| 26 |
+
this.elements.statusEl = document.getElementById('status');
|
| 27 |
+
|
| 28 |
+
// This event is dispatched when the user rates a reply. The system
|
| 29 |
+
// must then mark that reply and re-render it.
|
| 30 |
+
window.addEventListener('feedbackSubmitted', () => {
|
| 31 |
+
this.renderMessages();
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
this.attachEventListeners();
|
| 35 |
+
this.renderMessages();
|
| 36 |
+
},
|
| 37 |
+
|
| 38 |
+
/**
|
| 39 |
+
* Attach event listeners
|
| 40 |
+
*/
|
| 41 |
+
attachEventListeners() {
|
| 42 |
+
this.elements.sendBtn.addEventListener('click', () => this.sendMessage());
|
| 43 |
+
this.elements.clearBtn.addEventListener('click', () => this.clearConversation());
|
| 44 |
+
this.elements.systemPresetSelect.addEventListener('change', () => this.onModelChange());
|
| 45 |
+
|
| 46 |
+
// Enter to send, Shift+Enter = newline
|
| 47 |
+
this.elements.userInput.addEventListener('keydown', (e) => {
|
| 48 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 49 |
+
e.preventDefault();
|
| 50 |
+
this.sendMessage();
|
| 51 |
+
}
|
| 52 |
+
});
|
| 53 |
+
},
|
| 54 |
+
|
| 55 |
+
/**
|
| 56 |
+
* Render all messages in the chat window
|
| 57 |
+
*/
|
| 58 |
+
renderMessages() {
|
| 59 |
+
this.elements.chatWindow.innerHTML = '';
|
| 60 |
+
const modelType = this.elements.systemPresetSelect.value;
|
| 61 |
+
const messages = StateManager.getMessages(modelType);
|
| 62 |
+
|
| 63 |
+
messages.forEach((m, index) => {
|
| 64 |
+
const messageContainer = document.createElement('div');
|
| 65 |
+
messageContainer.classList.add('message-container');
|
| 66 |
+
|
| 67 |
+
const bubble = document.createElement('div');
|
| 68 |
+
bubble.classList.add(
|
| 69 |
+
'msg-bubble',
|
| 70 |
+
m.role === 'user' ? 'user' : 'assistant'
|
| 71 |
+
);
|
| 72 |
+
|
| 73 |
+
if (m.content === "no_reply") {
|
| 74 |
+
bubble.dataset.i18n = "no_reply";
|
| 75 |
+
} else {
|
| 76 |
+
// convert markdown to HTML safely
|
| 77 |
+
bubble.innerHTML = DOMPurify.sanitize(marked.parse(m.content));
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
messageContainer.appendChild(bubble);
|
| 81 |
+
|
| 82 |
+
// Add feedback buttons for assistant messages only
|
| 83 |
+
if (m.role === 'assistant' && m.content !== "no_reply") {
|
| 84 |
+
const feedbackButtons = this.createFeedbackButtons(index, modelType, m);
|
| 85 |
+
messageContainer.appendChild(feedbackButtons);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
this.elements.chatWindow.appendChild(messageContainer);
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
TranslationService.applyTranslation();
|
| 92 |
+
this.elements.chatWindow.scrollTop = this.elements.chatWindow.scrollHeight;
|
| 93 |
+
},
|
| 94 |
+
|
| 95 |
+
/**
|
| 96 |
+
* Create feedback buttons for a message
|
| 97 |
+
* @param {number} index - Message index
|
| 98 |
+
* @param {string} modelType - Model type
|
| 99 |
+
* @param {Object} message - Message object
|
| 100 |
+
* @returns {HTMLElement} Feedback buttons container
|
| 101 |
+
*/
|
| 102 |
+
createFeedbackButtons(index, modelType, message) {
|
| 103 |
+
const container = document.createElement('div');
|
| 104 |
+
container.classList.add('feedback-buttons');
|
| 105 |
+
|
| 106 |
+
// Check if already rated
|
| 107 |
+
const isRated = message.feedback?.rated;
|
| 108 |
+
const currentRating = message.feedback?.rating;
|
| 109 |
+
|
| 110 |
+
// Copy button
|
| 111 |
+
const copyBtn = document.createElement('button');
|
| 112 |
+
copyBtn.classList.add('feedback-btn', 'copy-btn');
|
| 113 |
+
copyBtn.innerHTML = '📋';
|
| 114 |
+
copyBtn.dataset.i18nTitle = "copy_reply_btn";
|
| 115 |
+
copyBtn.title = translations[StateManager.currentLang]["copy_reply_btn"];
|
| 116 |
+
copyBtn.addEventListener('click', () => {
|
| 117 |
+
this.copyMessage(message.content, copyBtn);
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
// Like button
|
| 121 |
+
const likeBtn = document.createElement('button');
|
| 122 |
+
likeBtn.classList.add('feedback-btn', 'like-feedback-btn');
|
| 123 |
+
if (isRated && currentRating === 'like') likeBtn.classList.add('active');
|
| 124 |
+
likeBtn.innerHTML = '👍';
|
| 125 |
+
likeBtn.dataset.i18nTitle = "feedback_like_btn";
|
| 126 |
+
likeBtn.title = translations[StateManager.currentLang]["feedback_like_btn"];
|
| 127 |
+
likeBtn.addEventListener('click', () => {
|
| 128 |
+
window.FeedbackComponent.openModal(index, modelType, 'like', message.content);
|
| 129 |
+
});
|
| 130 |
+
|
| 131 |
+
// Dislike button
|
| 132 |
+
const dislikeBtn = document.createElement('button');
|
| 133 |
+
dislikeBtn.classList.add('feedback-btn', 'dislike-feedback-btn');
|
| 134 |
+
if (isRated && currentRating === 'dislike') dislikeBtn.classList.add('active');
|
| 135 |
+
dislikeBtn.innerHTML = '👎';
|
| 136 |
+
dislikeBtn.dataset.i18nTitle = "feedback_dislike_btn";
|
| 137 |
+
dislikeBtn.title = translations[StateManager.currentLang]["feedback_dislike_btn"];
|
| 138 |
+
dislikeBtn.addEventListener('click', () => {
|
| 139 |
+
window.FeedbackComponent.openModal(index, modelType, 'dislike', message.content);
|
| 140 |
+
});
|
| 141 |
+
|
| 142 |
+
// Mixed button
|
| 143 |
+
const mixedBtn = document.createElement('button');
|
| 144 |
+
mixedBtn.classList.add('feedback-btn', 'mixed-feedback-btn');
|
| 145 |
+
if (isRated && currentRating === 'mixed') mixedBtn.classList.add('active');
|
| 146 |
+
mixedBtn.innerHTML = '~';
|
| 147 |
+
mixedBtn.dataset.i18nTitle = "feedback_mixed_btn";
|
| 148 |
+
mixedBtn.title = translations[StateManager.currentLang]["feedback_mixed_btn"];
|
| 149 |
+
mixedBtn.addEventListener('click', () => {
|
| 150 |
+
window.FeedbackComponent.openModal(index, modelType, 'mixed', message.content);
|
| 151 |
+
});
|
| 152 |
+
|
| 153 |
+
// TODO: 4 buttons is a lot. The copy button should be isolated in some way.
|
| 154 |
+
container.appendChild(copyBtn);
|
| 155 |
+
container.appendChild(likeBtn);
|
| 156 |
+
container.appendChild(dislikeBtn);
|
| 157 |
+
container.appendChild(mixedBtn);
|
| 158 |
+
|
| 159 |
+
return container;
|
| 160 |
+
},
|
| 161 |
+
|
| 162 |
+
/**
|
| 163 |
+
* Copy message content to clipboard
|
| 164 |
+
* @param {string} content - Message content to copy
|
| 165 |
+
* @param {HTMLElement} button - The copy button element
|
| 166 |
+
*/
|
| 167 |
+
async copyMessage(content, button) {
|
| 168 |
+
// Strip HTML and get plain text
|
| 169 |
+
const tempDiv = document.createElement('div');
|
| 170 |
+
tempDiv.innerHTML = DOMPurify.sanitize(marked.parse(content));
|
| 171 |
+
const plainText = tempDiv.innerText || tempDiv.textContent;
|
| 172 |
+
|
| 173 |
+
// Copy to clipboard
|
| 174 |
+
await navigator.clipboard.writeText(plainText);
|
| 175 |
+
|
| 176 |
+
// Visual feedback - change icon temporarily
|
| 177 |
+
const originalIcon = button.innerHTML;
|
| 178 |
+
button.innerHTML = '✓';
|
| 179 |
+
button.classList.add('copied');
|
| 180 |
+
|
| 181 |
+
// Show snackbar
|
| 182 |
+
showSnackbar(translations[StateManager.currentLang]["message_copied"], 'success', 2000);
|
| 183 |
+
|
| 184 |
+
// Reset after 2 seconds
|
| 185 |
+
setTimeout(() => {
|
| 186 |
+
button.innerHTML = originalIcon;
|
| 187 |
+
button.classList.remove('copied');
|
| 188 |
+
}, 2000);
|
| 189 |
+
},
|
| 190 |
+
|
| 191 |
+
/**
|
| 192 |
+
* Send a message to the chat
|
| 193 |
+
*/
|
| 194 |
+
async sendMessage() {
|
| 195 |
+
const text = this.elements.userInput.value.trim();
|
| 196 |
+
if (!text) return;
|
| 197 |
+
|
| 198 |
+
const modelType = this.elements.systemPresetSelect.value;
|
| 199 |
+
|
| 200 |
+
// Add user message locally
|
| 201 |
+
StateManager.addMessage(modelType, { role: 'user', content: text });
|
| 202 |
+
this.renderMessages();
|
| 203 |
+
this.elements.userInput.value = '';
|
| 204 |
+
|
| 205 |
+
// Update status
|
| 206 |
+
this.setStatus('thinking', 'info');
|
| 207 |
+
|
| 208 |
+
try {
|
| 209 |
+
const res = await ApiService.sendChatMessage(text, modelType);
|
| 210 |
+
const contentType = res.headers.get('content-type');
|
| 211 |
+
|
| 212 |
+
if (contentType && contentType.includes('application/json')) {
|
| 213 |
+
// Batch response
|
| 214 |
+
const data = await res.json();
|
| 215 |
+
const reply = data.reply || "no_reply";
|
| 216 |
+
StateManager.addMessage(modelType, { role: 'assistant', content: reply });
|
| 217 |
+
this.renderMessages();
|
| 218 |
+
} else {
|
| 219 |
+
// Streaming response
|
| 220 |
+
const assistantMessage = { role: 'assistant', content: '' };
|
| 221 |
+
StateManager.addMessage(modelType, assistantMessage);
|
| 222 |
+
|
| 223 |
+
const reader = res.body.getReader();
|
| 224 |
+
const decoder = new TextDecoder();
|
| 225 |
+
let done = false;
|
| 226 |
+
|
| 227 |
+
while (!done) {
|
| 228 |
+
const { value, done: readerDone } = await reader.read();
|
| 229 |
+
done = readerDone;
|
| 230 |
+
const chunk = decoder.decode(value, { stream: true });
|
| 231 |
+
assistantMessage.content += chunk;
|
| 232 |
+
this.renderMessages();
|
| 233 |
+
}
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
this.setStatus('ready', 'ok');
|
| 237 |
+
} catch (err) {
|
| 238 |
+
if (err.message === 'HTTP 400') {
|
| 239 |
+
this.setStatus('empty_message_error', 'error');
|
| 240 |
+
} else if (err.message.startsWith('HTTP')) {
|
| 241 |
+
this.setStatus('server_error', 'error');
|
| 242 |
+
} else {
|
| 243 |
+
this.setStatus('network_error', 'error');
|
| 244 |
+
}
|
| 245 |
+
}
|
| 246 |
+
},
|
| 247 |
+
|
| 248 |
+
/**
|
| 249 |
+
* Clear the conversation
|
| 250 |
+
*/
|
| 251 |
+
clearConversation() {
|
| 252 |
+
const modelType = this.elements.systemPresetSelect.value;
|
| 253 |
+
StateManager.clearConversation(modelType);
|
| 254 |
+
this.renderMessages();
|
| 255 |
+
this.setStatus('conversation_cleared', 'ok');
|
| 256 |
+
},
|
| 257 |
+
|
| 258 |
+
/**
|
| 259 |
+
* Handle model change
|
| 260 |
+
*/
|
| 261 |
+
onModelChange() {
|
| 262 |
+
this.setStatus('model_changed', 'ok');
|
| 263 |
+
this.renderMessages();
|
| 264 |
+
},
|
| 265 |
+
|
| 266 |
+
/**
|
| 267 |
+
* Set status message
|
| 268 |
+
* @param {string} messageKey - Translation key for the message
|
| 269 |
+
* @param {string} type - Status type ('ok', 'info', 'error')
|
| 270 |
+
*/
|
| 271 |
+
setStatus(messageKey, type) {
|
| 272 |
+
this.elements.statusEl.dataset.i18n = messageKey;
|
| 273 |
+
this.elements.statusEl.className = `status status-${type}`;
|
| 274 |
+
TranslationService.applyTranslation();
|
| 275 |
+
}
|
| 276 |
+
};
|
static/components/comment-component.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// components/comment-component.js - Comments functionality
|
| 2 |
+
|
| 3 |
+
import { StateManager } from '../services/state-manager.js';
|
| 4 |
+
import { ApiService } from '../services/api-service.js';
|
| 5 |
+
import { TranslationService } from '../services/translation-service.js';
|
| 6 |
+
|
| 7 |
+
export const CommentComponent = {
|
| 8 |
+
elements: {
|
| 9 |
+
leaveCommentText: null,
|
| 10 |
+
commentOverlay: null,
|
| 11 |
+
closeCommentBtn: null,
|
| 12 |
+
cancelCommentBtn: null,
|
| 13 |
+
sendCommentBtn: null,
|
| 14 |
+
commentInput: null,
|
| 15 |
+
statusComment: null
|
| 16 |
+
},
|
| 17 |
+
|
| 18 |
+
/**
|
| 19 |
+
* Initialize the comment component
|
| 20 |
+
*/
|
| 21 |
+
init() {
|
| 22 |
+
this.elements.leaveCommentText = document.getElementById('leave-comment');
|
| 23 |
+
this.elements.commentOverlay = document.getElementById('comment-overlay');
|
| 24 |
+
this.elements.closeCommentBtn = document.getElementById('closeCommentBtn');
|
| 25 |
+
this.elements.cancelCommentBtn = document.getElementById('cancelCommentBtn');
|
| 26 |
+
this.elements.sendCommentBtn = document.getElementById('sendCommentBtn');
|
| 27 |
+
this.elements.commentInput = document.getElementById('commentInput');
|
| 28 |
+
this.elements.statusComment = document.getElementById('commentStatus');
|
| 29 |
+
|
| 30 |
+
this.attachOutsideClickListener();
|
| 31 |
+
this.attachEventListeners();
|
| 32 |
+
this.initializeStatus();
|
| 33 |
+
},
|
| 34 |
+
|
| 35 |
+
attachOutsideClickListener() {
|
| 36 |
+
this.elements.commentOverlay.addEventListener('click', (e) => {
|
| 37 |
+
// Check if click is on the overlay itself (not its children)
|
| 38 |
+
if (e.target === this.elements.commentOverlay) {
|
| 39 |
+
this.closeOverlay();
|
| 40 |
+
}
|
| 41 |
+
});
|
| 42 |
+
},
|
| 43 |
+
|
| 44 |
+
/**
|
| 45 |
+
* Attach event listeners
|
| 46 |
+
*/
|
| 47 |
+
attachEventListeners() {
|
| 48 |
+
this.elements.leaveCommentText.addEventListener('click', (e) => this.openOverlay(e));
|
| 49 |
+
this.elements.closeCommentBtn.addEventListener('click', () => this.closeOverlay());
|
| 50 |
+
this.elements.cancelCommentBtn.addEventListener('click', () => this.closeOverlay());
|
| 51 |
+
this.elements.sendCommentBtn.addEventListener('click', () => this.sendComment());
|
| 52 |
+
|
| 53 |
+
// Enter to send, Shift+Enter = newline
|
| 54 |
+
this.elements.commentInput.addEventListener('keydown', (e) => {
|
| 55 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 56 |
+
e.preventDefault();
|
| 57 |
+
this.sendComment();
|
| 58 |
+
}
|
| 59 |
+
});
|
| 60 |
+
},
|
| 61 |
+
|
| 62 |
+
/**
|
| 63 |
+
* Initialize status display
|
| 64 |
+
*/
|
| 65 |
+
initializeStatus() {
|
| 66 |
+
this.elements.statusComment.dataset.i18n = "ready";
|
| 67 |
+
this.elements.statusComment.className = 'status-ok';
|
| 68 |
+
TranslationService.applyTranslation();
|
| 69 |
+
},
|
| 70 |
+
|
| 71 |
+
/**
|
| 72 |
+
* Open the comment overlay
|
| 73 |
+
*/
|
| 74 |
+
openOverlay(e) {
|
| 75 |
+
e.preventDefault();
|
| 76 |
+
this.elements.commentOverlay.style.display = '';
|
| 77 |
+
},
|
| 78 |
+
|
| 79 |
+
/**
|
| 80 |
+
* Close the comment overlay
|
| 81 |
+
*/
|
| 82 |
+
closeOverlay() {
|
| 83 |
+
this.elements.commentOverlay.style.display = 'none';
|
| 84 |
+
},
|
| 85 |
+
|
| 86 |
+
/**
|
| 87 |
+
* Send a comment to the server
|
| 88 |
+
*/
|
| 89 |
+
async sendComment() {
|
| 90 |
+
const comment = this.elements.commentInput.value;
|
| 91 |
+
if (!comment) return;
|
| 92 |
+
|
| 93 |
+
this.setStatus('sending', 'info');
|
| 94 |
+
|
| 95 |
+
const result = await ApiService.sendComment(comment);
|
| 96 |
+
|
| 97 |
+
if (!result.success) {
|
| 98 |
+
if (result.status === 400) {
|
| 99 |
+
this.setStatus('empty_message_error', 'error');
|
| 100 |
+
} else {
|
| 101 |
+
this.setStatus('server_error', 'error');
|
| 102 |
+
}
|
| 103 |
+
return;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
this.elements.commentInput.value = '';
|
| 107 |
+
this.setStatus('comment_sent', 'ok');
|
| 108 |
+
},
|
| 109 |
+
|
| 110 |
+
/**
|
| 111 |
+
* Set status message
|
| 112 |
+
* @param {string} messageKey - Translation key for the message
|
| 113 |
+
* @param {string} type - Status type ('ok', 'info', 'error')
|
| 114 |
+
*/
|
| 115 |
+
setStatus(messageKey, type) {
|
| 116 |
+
this.elements.statusComment.dataset.i18n = messageKey;
|
| 117 |
+
this.elements.statusComment.className = `status-${type}`;
|
| 118 |
+
TranslationService.applyTranslation();
|
| 119 |
+
}
|
| 120 |
+
};
|
static/components/consent-component.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// components/consent-component.js - Consent modal functionality
|
| 2 |
+
|
| 3 |
+
import { StateManager } from '../services/state-manager.js';
|
| 4 |
+
|
| 5 |
+
export const ConsentComponent = {
|
| 6 |
+
elements: {
|
| 7 |
+
consentModal: null,
|
| 8 |
+
consentCheckbox: null,
|
| 9 |
+
consentBtn: null,
|
| 10 |
+
profileModal: null
|
| 11 |
+
},
|
| 12 |
+
|
| 13 |
+
/**
|
| 14 |
+
* Initialize the consent component
|
| 15 |
+
*/
|
| 16 |
+
init() {
|
| 17 |
+
this.elements.consentModal = document.getElementById('consent-modal');
|
| 18 |
+
this.elements.consentCheckbox = document.getElementById('consent-checkbox');
|
| 19 |
+
this.elements.consentBtn = document.getElementById('consentBtn');
|
| 20 |
+
this.elements.profileModal = document.getElementById('profile-modal');
|
| 21 |
+
|
| 22 |
+
this.attachEventListeners();
|
| 23 |
+
},
|
| 24 |
+
|
| 25 |
+
/**
|
| 26 |
+
* Attach event listeners
|
| 27 |
+
*/
|
| 28 |
+
attachEventListeners() {
|
| 29 |
+
// When the checkbox is toggled, enable or disable the button
|
| 30 |
+
this.elements.consentCheckbox.addEventListener('change', () => {
|
| 31 |
+
if (this.elements.consentCheckbox.checked) {
|
| 32 |
+
this.elements.consentBtn.disabled = false;
|
| 33 |
+
this.elements.consentBtn.classList.replace('disabled-button', 'ok-button');
|
| 34 |
+
} else {
|
| 35 |
+
this.elements.consentBtn.disabled = true;
|
| 36 |
+
this.elements.consentBtn.classList.replace('ok-button', 'disabled-button');
|
| 37 |
+
}
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
// Handle the consent acceptance
|
| 41 |
+
this.elements.consentBtn.addEventListener('click', () => {
|
| 42 |
+
StateManager.setConsent(true);
|
| 43 |
+
this.elements.profileModal.scrollIntoView({
|
| 44 |
+
behavior: 'smooth',
|
| 45 |
+
inline: 'start',
|
| 46 |
+
block: 'nearest'
|
| 47 |
+
});
|
| 48 |
+
});
|
| 49 |
+
}
|
| 50 |
+
};
|
static/components/feedback-component.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// components/feedback-component.js - Message feedback functionality
|
| 2 |
+
|
| 3 |
+
import { StateManager } from '../services/state-manager.js';
|
| 4 |
+
import { ApiService } from '../services/api-service.js';
|
| 5 |
+
import { TranslationService } from '../services/translation-service.js';
|
| 6 |
+
import { Utils } from '../utils.js';
|
| 7 |
+
|
| 8 |
+
export const FeedbackComponent = {
|
| 9 |
+
elements: {
|
| 10 |
+
feedbackOverlay: null,
|
| 11 |
+
closeFeedbackBtn: null,
|
| 12 |
+
cancelFeedbackBtn: null,
|
| 13 |
+
submitFeedbackBtn: null,
|
| 14 |
+
feedbackInput: null,
|
| 15 |
+
feedbackRatingDisplay: null,
|
| 16 |
+
feedbackMessagePreview: null
|
| 17 |
+
},
|
| 18 |
+
|
| 19 |
+
currentFeedback: {
|
| 20 |
+
messageIndex: null,
|
| 21 |
+
modelType: null,
|
| 22 |
+
rating: null, // 'like', 'dislike', 'mixed'
|
| 23 |
+
messageContent: null
|
| 24 |
+
},
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
* Initialize the feedback component
|
| 28 |
+
*/
|
| 29 |
+
init() {
|
| 30 |
+
// Get DOM elements
|
| 31 |
+
this.elements.feedbackOverlay = document.getElementById('feedback-overlay');
|
| 32 |
+
this.elements.closeFeedbackBtn = document.getElementById('closeFeedbackBtn');
|
| 33 |
+
this.elements.cancelFeedbackBtn = document.getElementById('cancelFeedbackBtn');
|
| 34 |
+
this.elements.submitFeedbackBtn = document.getElementById('submitFeedbackBtn');
|
| 35 |
+
this.elements.feedbackInput = document.getElementById('feedbackInput');
|
| 36 |
+
this.elements.feedbackRatingDisplay = document.getElementById('feedbackRatingDisplay');
|
| 37 |
+
this.elements.feedbackMessagePreview = document.getElementById('feedbackMessagePreview');
|
| 38 |
+
|
| 39 |
+
this.attachOutsideClickListener();
|
| 40 |
+
this.attachEventListeners();
|
| 41 |
+
},
|
| 42 |
+
|
| 43 |
+
attachOutsideClickListener() {
|
| 44 |
+
this.elements.feedbackOverlay.addEventListener('click', (e) => {
|
| 45 |
+
// Check if click is on the overlay itself (not its children)
|
| 46 |
+
if (e.target === this.elements.feedbackOverlay) {
|
| 47 |
+
this.closeModal();
|
| 48 |
+
}
|
| 49 |
+
});
|
| 50 |
+
},
|
| 51 |
+
|
| 52 |
+
/**
|
| 53 |
+
* Attach event listeners
|
| 54 |
+
*/
|
| 55 |
+
attachEventListeners() {
|
| 56 |
+
this.elements.closeFeedbackBtn.addEventListener('click', () => this.closeModal());
|
| 57 |
+
this.elements.cancelFeedbackBtn.addEventListener('click', () => this.closeModal());
|
| 58 |
+
this.elements.submitFeedbackBtn.addEventListener('click', () => this.submitFeedback());
|
| 59 |
+
|
| 60 |
+
// Enter to submit (optional comment)
|
| 61 |
+
this.elements.feedbackInput.addEventListener('keydown', (e) => {
|
| 62 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 63 |
+
e.preventDefault();
|
| 64 |
+
this.submitFeedback();
|
| 65 |
+
}
|
| 66 |
+
});
|
| 67 |
+
},
|
| 68 |
+
|
| 69 |
+
/**
|
| 70 |
+
* Open feedback modal
|
| 71 |
+
* @param {number} messageIndex - Index of the message
|
| 72 |
+
* @param {string} modelType - Type of model
|
| 73 |
+
* @param {string} rating - 'like', 'dislike', or 'mixed'
|
| 74 |
+
* @param {string} messageContent - Content of the message being rated
|
| 75 |
+
*/
|
| 76 |
+
openModal(messageIndex, modelType, rating, messageContent) {
|
| 77 |
+
this.currentFeedback = {
|
| 78 |
+
messageIndex,
|
| 79 |
+
modelType,
|
| 80 |
+
rating,
|
| 81 |
+
messageContent
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
// Update modal content
|
| 85 |
+
this.updateModalContent(rating, messageContent);
|
| 86 |
+
|
| 87 |
+
// Show modal
|
| 88 |
+
this.elements.feedbackOverlay.style.display = '';
|
| 89 |
+
|
| 90 |
+
// Focus on textarea
|
| 91 |
+
setTimeout(() => this.elements.feedbackInput.focus(), 100);
|
| 92 |
+
},
|
| 93 |
+
|
| 94 |
+
/**
|
| 95 |
+
* Update modal content based on rating
|
| 96 |
+
* @param {string} rating - The rating type
|
| 97 |
+
* @param {string} messageContent - The message content
|
| 98 |
+
*/
|
| 99 |
+
updateModalContent(rating, messageContent) {
|
| 100 |
+
// Update rating display
|
| 101 |
+
const ratingEmoji = {
|
| 102 |
+
'like': '👍',
|
| 103 |
+
'dislike': '👎',
|
| 104 |
+
'mixed': '~'
|
| 105 |
+
};
|
| 106 |
+
|
| 107 |
+
const ratingText = {
|
| 108 |
+
'like': 'feedback_like_title',
|
| 109 |
+
'dislike': 'feedback_dislike_title',
|
| 110 |
+
'mixed': 'feedback_neutral_title'
|
| 111 |
+
};
|
| 112 |
+
|
| 113 |
+
this.elements.feedbackRatingDisplay.textContent = ratingEmoji[rating] + ' ';
|
| 114 |
+
this.elements.feedbackRatingDisplay.dataset.i18n = ratingText[rating];
|
| 115 |
+
|
| 116 |
+
// Update message preview (truncate if too long)
|
| 117 |
+
const preview = messageContent.length > 150
|
| 118 |
+
? messageContent.substring(0, 150) + '...'
|
| 119 |
+
: messageContent;
|
| 120 |
+
this.elements.feedbackMessagePreview.textContent = preview;
|
| 121 |
+
|
| 122 |
+
// Clear previous comment
|
| 123 |
+
this.elements.feedbackInput.value = '';
|
| 124 |
+
|
| 125 |
+
// Apply translations
|
| 126 |
+
TranslationService.applyTranslation();
|
| 127 |
+
},
|
| 128 |
+
|
| 129 |
+
/**
|
| 130 |
+
* Close the feedback modal
|
| 131 |
+
*/
|
| 132 |
+
closeModal() {
|
| 133 |
+
this.elements.feedbackOverlay.style.display = 'none';
|
| 134 |
+
this.currentFeedback = {
|
| 135 |
+
messageIndex: null,
|
| 136 |
+
modelType: null,
|
| 137 |
+
rating: null,
|
| 138 |
+
messageContent: null
|
| 139 |
+
};
|
| 140 |
+
},
|
| 141 |
+
|
| 142 |
+
/**
|
| 143 |
+
* Submit feedback to the server
|
| 144 |
+
*/
|
| 145 |
+
async submitFeedback() {
|
| 146 |
+
const comment = this.elements.feedbackInput.value.trim();
|
| 147 |
+
|
| 148 |
+
const feedbackData = {
|
| 149 |
+
message_index: this.currentFeedback.messageIndex,
|
| 150 |
+
model_type: this.currentFeedback.modelType,
|
| 151 |
+
rating: this.currentFeedback.rating,
|
| 152 |
+
comment: comment || "", // Optional
|
| 153 |
+
reply_content: this.currentFeedback.messageContent,
|
| 154 |
+
user_id: Utils.getMachineId(),
|
| 155 |
+
session_id: StateManager.sessionId,
|
| 156 |
+
conversation_id: StateManager.getConversationId(this.currentFeedback.modelType)
|
| 157 |
+
};
|
| 158 |
+
|
| 159 |
+
try {
|
| 160 |
+
const result = await ApiService.submitFeedback(feedbackData);
|
| 161 |
+
|
| 162 |
+
if (result.success) {
|
| 163 |
+
showSnackbar(translations[StateManager.currentLang]["feedback_submitted"], 'success');
|
| 164 |
+
|
| 165 |
+
// Mark message as rated in state (optional - for UI indication)
|
| 166 |
+
this.markMessageAsRated(
|
| 167 |
+
this.currentFeedback.modelType,
|
| 168 |
+
this.currentFeedback.messageIndex,
|
| 169 |
+
this.currentFeedback.rating
|
| 170 |
+
);
|
| 171 |
+
|
| 172 |
+
this.closeModal();
|
| 173 |
+
} else {
|
| 174 |
+
showSnackbar(translations[StateManager.currentLang]["feedback_failed_server_error"], 'error');
|
| 175 |
+
}
|
| 176 |
+
} catch (err) {
|
| 177 |
+
showSnackbar(translations[StateManager.currentLang]["feedback_failed_network_error"], 'error');
|
| 178 |
+
}
|
| 179 |
+
},
|
| 180 |
+
|
| 181 |
+
/**
|
| 182 |
+
* Mark a message as rated (for UI purposes)
|
| 183 |
+
* @param {string} modelType - Model type
|
| 184 |
+
* @param {number} messageIndex - Message index
|
| 185 |
+
* @param {string} rating - Rating given
|
| 186 |
+
*/
|
| 187 |
+
markMessageAsRated(modelType, messageIndex, rating) {
|
| 188 |
+
const messages = StateManager.getMessages(modelType);
|
| 189 |
+
if (messages[messageIndex]) {
|
| 190 |
+
messages[messageIndex].feedback = {
|
| 191 |
+
rated: true,
|
| 192 |
+
rating: rating
|
| 193 |
+
};
|
| 194 |
+
|
| 195 |
+
window.dispatchEvent(new CustomEvent('feedbackSubmitted', {
|
| 196 |
+
detail: { modelType, messageIndex, rating }
|
| 197 |
+
}));
|
| 198 |
+
}
|
| 199 |
+
}
|
| 200 |
+
};
|
static/components/file-upload-component.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// components/file-upload-component.js - File upload and management
|
| 2 |
+
|
| 3 |
+
import { Utils } from '../utils.js';
|
| 4 |
+
import { StateManager } from '../services/state-manager.js';
|
| 5 |
+
import { ApiService } from '../services/api-service.js';
|
| 6 |
+
import { TranslationService } from '../services/translation-service.js';
|
| 7 |
+
|
| 8 |
+
export const FileUploadComponent = {
|
| 9 |
+
elements: {
|
| 10 |
+
uploadFileBtn: null,
|
| 11 |
+
uploadFileOverlay: null,
|
| 12 |
+
fileDropZone: null,
|
| 13 |
+
fileInput: null,
|
| 14 |
+
doneFileUploadBtn: null,
|
| 15 |
+
closeFileUploadBtn: null,
|
| 16 |
+
fileListHtml: null
|
| 17 |
+
},
|
| 18 |
+
|
| 19 |
+
constants: {
|
| 20 |
+
FILE_SIZE_LIMIT: 10 * 1024 * 1024, // 10 MB
|
| 21 |
+
TOTAL_FILE_SIZE_LIMIT: 30 * 1024 * 1024, // 30 MB
|
| 22 |
+
MAX_FILE_NAME_LENGTH: 50,
|
| 23 |
+
ALLOWED_TYPES: ['.pdf', '.txt', '.docx', '.jpg', '.jpeg', '.png']
|
| 24 |
+
},
|
| 25 |
+
|
| 26 |
+
icons: {
|
| 27 |
+
upload: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 28 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
| 29 |
+
</svg>`,
|
| 30 |
+
spinner: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" class="spinning">
|
| 31 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
| 32 |
+
</svg>`,
|
| 33 |
+
check: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 34 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
| 35 |
+
</svg>`,
|
| 36 |
+
trash: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 37 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
| 38 |
+
</svg>`
|
| 39 |
+
},
|
| 40 |
+
|
| 41 |
+
/**
|
| 42 |
+
* Initialize the file upload component
|
| 43 |
+
*/
|
| 44 |
+
init() {
|
| 45 |
+
this.elements.uploadFileBtn = document.getElementById('upload-file-btn');
|
| 46 |
+
this.elements.uploadFileOverlay = document.getElementById('upload-file-overlay');
|
| 47 |
+
this.elements.fileDropZone = document.getElementById('file-drop-zone');
|
| 48 |
+
this.elements.fileInput = document.getElementById('file-input');
|
| 49 |
+
this.elements.doneFileUploadBtn = document.getElementById('done-file-upload');
|
| 50 |
+
this.elements.closeFileUploadBtn = document.getElementById('close-file-upload-btn');
|
| 51 |
+
this.elements.fileListHtml = document.getElementById('file-list');
|
| 52 |
+
|
| 53 |
+
this.attachOutsideClickListener();
|
| 54 |
+
this.attachEventListeners();
|
| 55 |
+
this.renderFiles();
|
| 56 |
+
},
|
| 57 |
+
|
| 58 |
+
attachOutsideClickListener() {
|
| 59 |
+
this.elements.uploadFileOverlay.addEventListener('click', (e) => {
|
| 60 |
+
// Check if click is on the overlay itself (not its children)
|
| 61 |
+
if (e.target === this.elements.uploadFileOverlay) {
|
| 62 |
+
this.closeOverlay();
|
| 63 |
+
}
|
| 64 |
+
});
|
| 65 |
+
},
|
| 66 |
+
|
| 67 |
+
/**
|
| 68 |
+
* Attach event listeners
|
| 69 |
+
*/
|
| 70 |
+
attachEventListeners() {
|
| 71 |
+
this.elements.uploadFileBtn.addEventListener('click', (e) => this.openOverlay(e));
|
| 72 |
+
this.elements.fileDropZone.addEventListener('click', () => this.elements.fileInput.click());
|
| 73 |
+
this.elements.closeFileUploadBtn.addEventListener('click', () => this.closeOverlay());
|
| 74 |
+
this.elements.doneFileUploadBtn.addEventListener('click', () => this.closeOverlay());
|
| 75 |
+
|
| 76 |
+
// Prevent the browser from opening a dropped file
|
| 77 |
+
['dragover', 'drop'].forEach(eventName => {
|
| 78 |
+
this.elements.fileDropZone.addEventListener(eventName, (e) => e.preventDefault());
|
| 79 |
+
});
|
| 80 |
+
|
| 81 |
+
this.elements.fileDropZone.addEventListener('dragover', () => {
|
| 82 |
+
this.elements.fileDropZone.classList.add('active');
|
| 83 |
+
});
|
| 84 |
+
|
| 85 |
+
// File drop logic
|
| 86 |
+
this.elements.fileDropZone.addEventListener('drop', (e) => {
|
| 87 |
+
this.elements.fileDropZone.classList.remove('active');
|
| 88 |
+
const addedFiles = Array.from(e.dataTransfer.files);
|
| 89 |
+
this.handleFileAddition(addedFiles);
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
+
// File browsing logic
|
| 93 |
+
this.elements.fileInput.addEventListener('change', (e) => {
|
| 94 |
+
const addedFiles = Array.from(e.target.files);
|
| 95 |
+
this.handleFileAddition(addedFiles);
|
| 96 |
+
});
|
| 97 |
+
},
|
| 98 |
+
|
| 99 |
+
/**
|
| 100 |
+
* Open the upload overlay
|
| 101 |
+
*/
|
| 102 |
+
openOverlay(e) {
|
| 103 |
+
e.preventDefault();
|
| 104 |
+
this.elements.uploadFileOverlay.style.display = '';
|
| 105 |
+
},
|
| 106 |
+
|
| 107 |
+
/**
|
| 108 |
+
* Close the upload overlay
|
| 109 |
+
*/
|
| 110 |
+
closeOverlay() {
|
| 111 |
+
this.elements.uploadFileOverlay.style.display = 'none';
|
| 112 |
+
},
|
| 113 |
+
|
| 114 |
+
/**
|
| 115 |
+
* Handle file addition (drop or browse)
|
| 116 |
+
* @param {Array<File>} newFiles - Array of new files
|
| 117 |
+
*/
|
| 118 |
+
async handleFileAddition(newFiles) {
|
| 119 |
+
const isProcessingSuccessful = this.processFiles(newFiles);
|
| 120 |
+
if (!isProcessingSuccessful) {
|
| 121 |
+
return;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
newFiles.forEach(file => StateManager.addFile(file));
|
| 125 |
+
|
| 126 |
+
// Upload files
|
| 127 |
+
newFiles.forEach(async (file) => {
|
| 128 |
+
file.state = 'uploading';
|
| 129 |
+
this.renderFiles();
|
| 130 |
+
const isUploadSuccessful = await ApiService.uploadFile(file);
|
| 131 |
+
file.state = isUploadSuccessful ? 'uploaded' : 'ready';
|
| 132 |
+
this.renderFiles();
|
| 133 |
+
});
|
| 134 |
+
|
| 135 |
+
this.renderFiles();
|
| 136 |
+
},
|
| 137 |
+
|
| 138 |
+
/**
|
| 139 |
+
* Validate files before adding
|
| 140 |
+
* @param {Array<File>} newFiles - Array of files to validate
|
| 141 |
+
* @returns {boolean} Whether files are valid
|
| 142 |
+
*/
|
| 143 |
+
processFiles(newFiles) {
|
| 144 |
+
// Check file types
|
| 145 |
+
const unallowedFiles = newFiles.filter((file) =>
|
| 146 |
+
!this.constants.ALLOWED_TYPES.some(ext => file.name.endsWith(ext))
|
| 147 |
+
);
|
| 148 |
+
|
| 149 |
+
if (unallowedFiles.length > 0) {
|
| 150 |
+
newFiles.forEach((file) => Utils.removeFileFromInput(this.elements.fileInput, file));
|
| 151 |
+
showSnackbar(translations[StateManager.currentLang]["error_file_format"], "error");
|
| 152 |
+
return false;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
// Check individual file size
|
| 156 |
+
const largeFiles = newFiles.filter((file) => file.size > this.constants.FILE_SIZE_LIMIT);
|
| 157 |
+
if (largeFiles.length > 0) {
|
| 158 |
+
newFiles.forEach((file) => Utils.removeFileFromInput(this.elements.fileInput, file));
|
| 159 |
+
showSnackbar(translations[StateManager.currentLang]["error_file_size"], "error");
|
| 160 |
+
return false;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
// Check total file size
|
| 164 |
+
const totalFileSize = [...newFiles, ...StateManager.getFiles()].reduce((sum, file) => sum + file.size, 0);
|
| 165 |
+
if (totalFileSize > this.constants.TOTAL_FILE_SIZE_LIMIT) {
|
| 166 |
+
newFiles.forEach((file) => Utils.removeFileFromInput(this.elements.fileInput, file));
|
| 167 |
+
showSnackbar(translations[StateManager.currentLang]["error_total_file_size"], "error");
|
| 168 |
+
return false;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
// Check file name length
|
| 172 |
+
const filesWithLongName = newFiles.filter((file) => file.name.length > this.constants.MAX_FILE_NAME_LENGTH);
|
| 173 |
+
if (filesWithLongName.length > 0) {
|
| 174 |
+
newFiles.forEach((file) => Utils.removeFileFromInput(this.elements.fileInput, file));
|
| 175 |
+
showSnackbar(translations[StateManager.currentLang]["error_file_name_length"], "error");
|
| 176 |
+
return false;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
return true;
|
| 180 |
+
},
|
| 181 |
+
|
| 182 |
+
/**
|
| 183 |
+
* Render the file list
|
| 184 |
+
*/
|
| 185 |
+
renderFiles() {
|
| 186 |
+
this.elements.fileListHtml.innerHTML = '';
|
| 187 |
+
const sessionFiles = StateManager.getFiles();
|
| 188 |
+
|
| 189 |
+
if (sessionFiles.length === 0) {
|
| 190 |
+
const noFileMessage = document.createElement('div');
|
| 191 |
+
noFileMessage.classList.add('no-file');
|
| 192 |
+
noFileMessage.dataset.i18n = "no_files";
|
| 193 |
+
this.elements.fileListHtml.appendChild(noFileMessage);
|
| 194 |
+
TranslationService.applyTranslation();
|
| 195 |
+
return;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
sessionFiles.forEach((f) => {
|
| 199 |
+
const fileItem = document.createElement('div');
|
| 200 |
+
fileItem.classList.add('file-item');
|
| 201 |
+
fileItem.textContent = f.name;
|
| 202 |
+
|
| 203 |
+
const fileActions = document.createElement('div');
|
| 204 |
+
fileActions.classList.add('file-actions');
|
| 205 |
+
|
| 206 |
+
const uploadButton = this.createUploadButton(f);
|
| 207 |
+
const deleteButton = this.createDeleteButton(f);
|
| 208 |
+
|
| 209 |
+
fileActions.appendChild(uploadButton);
|
| 210 |
+
fileActions.appendChild(deleteButton);
|
| 211 |
+
fileItem.appendChild(fileActions);
|
| 212 |
+
this.elements.fileListHtml.appendChild(fileItem);
|
| 213 |
+
});
|
| 214 |
+
|
| 215 |
+
TranslationService.applyTranslation();
|
| 216 |
+
},
|
| 217 |
+
|
| 218 |
+
/**
|
| 219 |
+
* Create upload button for a file
|
| 220 |
+
* @param {File} file - File object
|
| 221 |
+
* @returns {HTMLButtonElement} Upload button
|
| 222 |
+
*/
|
| 223 |
+
createUploadButton(file) {
|
| 224 |
+
const uploadButton = document.createElement('button');
|
| 225 |
+
|
| 226 |
+
if (file.state === 'uploaded') {
|
| 227 |
+
uploadButton.innerHTML = this.icons.check + `<span data-i18n="file_uploaded"></span>`;
|
| 228 |
+
uploadButton.classList.add('disabled-button');
|
| 229 |
+
uploadButton.disabled = true;
|
| 230 |
+
} else if (file.state === 'uploading') {
|
| 231 |
+
uploadButton.innerHTML = this.icons.spinner + `<span data-i18n="file_uploading"></span>`;
|
| 232 |
+
uploadButton.classList.add('disabled-button');
|
| 233 |
+
uploadButton.disabled = true;
|
| 234 |
+
} else if (file.state === 'ready') {
|
| 235 |
+
uploadButton.innerHTML = this.icons.upload + `<span data-i18n="file_upload"></span>`;
|
| 236 |
+
uploadButton.classList.add('ok-button');
|
| 237 |
+
uploadButton.addEventListener('click', async () => {
|
| 238 |
+
file.state = 'uploading';
|
| 239 |
+
this.renderFiles();
|
| 240 |
+
const isUploadSuccessful = await ApiService.uploadFile(file);
|
| 241 |
+
file.state = isUploadSuccessful ? 'uploaded' : 'ready';
|
| 242 |
+
this.renderFiles();
|
| 243 |
+
});
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
return uploadButton;
|
| 247 |
+
},
|
| 248 |
+
|
| 249 |
+
/**
|
| 250 |
+
* Create delete button for a file
|
| 251 |
+
* @param {File} file - File object
|
| 252 |
+
* @returns {HTMLButtonElement} Delete button
|
| 253 |
+
*/
|
| 254 |
+
createDeleteButton(file) {
|
| 255 |
+
const deleteButton = document.createElement('button');
|
| 256 |
+
deleteButton.innerHTML = this.icons.trash + `<span data-i18n="file_delete"></span>`;
|
| 257 |
+
deleteButton.classList.add('no-button');
|
| 258 |
+
deleteButton.addEventListener('click', async () => {
|
| 259 |
+
// No need to send a request to the server if the file was not uploaded
|
| 260 |
+
const isDeletionSuccessful = file.state === 'uploaded'
|
| 261 |
+
? await ApiService.deleteFile(file)
|
| 262 |
+
: true;
|
| 263 |
+
|
| 264 |
+
if (isDeletionSuccessful) {
|
| 265 |
+
Utils.removeFileFromInput(this.elements.fileInput, file);
|
| 266 |
+
StateManager.removeFile(file);
|
| 267 |
+
this.renderFiles();
|
| 268 |
+
}
|
| 269 |
+
});
|
| 270 |
+
|
| 271 |
+
return deleteButton;
|
| 272 |
+
}
|
| 273 |
+
};
|
static/components/language-component.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// components/language-component.js - Language selection modal
|
| 2 |
+
|
| 3 |
+
import { StateManager } from '../services/state-manager.js';
|
| 4 |
+
import { TranslationService } from '../services/translation-service.js';
|
| 5 |
+
|
| 6 |
+
export const LanguageComponent = {
|
| 7 |
+
elements: {
|
| 8 |
+
frRadioBtn: null,
|
| 9 |
+
enRadioBtn: null,
|
| 10 |
+
continueLangBtn: null,
|
| 11 |
+
consentModal: null
|
| 12 |
+
},
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* Initialize the language component
|
| 16 |
+
*/
|
| 17 |
+
init() {
|
| 18 |
+
this.elements.frRadioBtn = document.getElementById('lang-fr');
|
| 19 |
+
this.elements.enRadioBtn = document.getElementById('lang-en');
|
| 20 |
+
this.elements.continueLangBtn = document.getElementById('lang-continue-btn');
|
| 21 |
+
this.elements.consentModal = document.getElementById('consent-modal');
|
| 22 |
+
|
| 23 |
+
this.attachEventListeners();
|
| 24 |
+
this.setInitialLanguage();
|
| 25 |
+
},
|
| 26 |
+
|
| 27 |
+
/**
|
| 28 |
+
* Attach event listeners
|
| 29 |
+
*/
|
| 30 |
+
attachEventListeners() {
|
| 31 |
+
this.elements.frRadioBtn.addEventListener('change', () => {
|
| 32 |
+
TranslationService.setLanguage(this.elements.frRadioBtn.value);
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
this.elements.enRadioBtn.addEventListener('change', () => {
|
| 36 |
+
TranslationService.setLanguage(this.elements.enRadioBtn.value);
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
this.elements.continueLangBtn.addEventListener('click', () => {
|
| 40 |
+
this.elements.consentModal.scrollIntoView({
|
| 41 |
+
behavior: 'smooth',
|
| 42 |
+
inline: 'start',
|
| 43 |
+
block: 'nearest'
|
| 44 |
+
});
|
| 45 |
+
});
|
| 46 |
+
},
|
| 47 |
+
|
| 48 |
+
/**
|
| 49 |
+
* Set initial language radio button state
|
| 50 |
+
*/
|
| 51 |
+
setInitialLanguage() {
|
| 52 |
+
if (StateManager.currentLang === 'en') {
|
| 53 |
+
this.elements.enRadioBtn.checked = true;
|
| 54 |
+
} else {
|
| 55 |
+
this.elements.frRadioBtn.checked = true;
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
};
|
static/components/profile-component.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// components/profile-component.js - Profile modal functionality
|
| 2 |
+
|
| 3 |
+
import { StateManager } from '../services/state-manager.js';
|
| 4 |
+
|
| 5 |
+
export const ProfileComponent = {
|
| 6 |
+
elements: {
|
| 7 |
+
profileModal: null,
|
| 8 |
+
profileBtn: null,
|
| 9 |
+
ageGroupInput: null,
|
| 10 |
+
genderInput: null,
|
| 11 |
+
roleInputs: null,
|
| 12 |
+
participantInput: null,
|
| 13 |
+
welcomePopup: null
|
| 14 |
+
},
|
| 15 |
+
|
| 16 |
+
/**
|
| 17 |
+
* Initialize the profile component
|
| 18 |
+
*/
|
| 19 |
+
init() {
|
| 20 |
+
this.elements.profileModal = document.getElementById('profile-modal');
|
| 21 |
+
this.elements.profileBtn = document.getElementById('profileBtn');
|
| 22 |
+
this.elements.ageGroupInput = document.getElementById('age-group');
|
| 23 |
+
this.elements.genderInput = document.getElementById('gender');
|
| 24 |
+
this.elements.roleInputs = document.querySelectorAll('input[name="role"]');
|
| 25 |
+
this.elements.participantInput = document.getElementById('participant-id');
|
| 26 |
+
this.elements.welcomePopup = document.getElementById('welcomePopup');
|
| 27 |
+
|
| 28 |
+
this.attachEventListeners();
|
| 29 |
+
},
|
| 30 |
+
|
| 31 |
+
/**
|
| 32 |
+
* Attach event listeners
|
| 33 |
+
*/
|
| 34 |
+
attachEventListeners() {
|
| 35 |
+
// Add listeners to validate profile on input change
|
| 36 |
+
this.elements.genderInput.addEventListener('click', () => this.checkProfileValidity());
|
| 37 |
+
this.elements.ageGroupInput.addEventListener('click', () => this.checkProfileValidity());
|
| 38 |
+
this.elements.roleInputs.forEach(input =>
|
| 39 |
+
input.addEventListener('change', () => this.checkProfileValidity())
|
| 40 |
+
);
|
| 41 |
+
this.elements.participantInput.addEventListener('input', () => this.checkParticipantIdInput());
|
| 42 |
+
this.elements.participantInput.addEventListener('input', () => this.checkProfileValidity());
|
| 43 |
+
|
| 44 |
+
// Handle profile submission
|
| 45 |
+
this.elements.profileBtn.addEventListener('click', () => this.submitProfile());
|
| 46 |
+
},
|
| 47 |
+
|
| 48 |
+
/**
|
| 49 |
+
* Check if profile form is valid and enable/disable button accordingly
|
| 50 |
+
*/
|
| 51 |
+
checkProfileValidity() {
|
| 52 |
+
// 1. Check if any gender is selected
|
| 53 |
+
const genderSelected = this.elements.genderInput.value !== '';
|
| 54 |
+
|
| 55 |
+
// 2. Check if any age group is selected
|
| 56 |
+
const ageSelected = this.elements.ageGroupInput.value !== '';
|
| 57 |
+
|
| 58 |
+
// 3. Check if at least one role checkbox is selected
|
| 59 |
+
const roleSelected = Array.from(this.elements.roleInputs).some(input => input.checked);
|
| 60 |
+
|
| 61 |
+
// 4. Check if the participant id field has a value
|
| 62 |
+
const participantIdEntered = this.elements.participantInput.value.trim().length > 0;
|
| 63 |
+
|
| 64 |
+
// 5. Enable button only if all are true
|
| 65 |
+
if (genderSelected && ageSelected && roleSelected && participantIdEntered) {
|
| 66 |
+
this.elements.profileBtn.disabled = false;
|
| 67 |
+
this.elements.profileBtn.classList.replace('disabled-button', 'ok-button');
|
| 68 |
+
} else {
|
| 69 |
+
this.elements.profileBtn.disabled = true;
|
| 70 |
+
this.elements.profileBtn.classList.replace('ok-button', 'disabled-button');
|
| 71 |
+
}
|
| 72 |
+
},
|
| 73 |
+
|
| 74 |
+
/**
|
| 75 |
+
* Submit profile and close welcome popup
|
| 76 |
+
*/
|
| 77 |
+
submitProfile() {
|
| 78 |
+
const profileData = {
|
| 79 |
+
ageGroup: this.elements.ageGroupInput.value,
|
| 80 |
+
gender: this.elements.genderInput.value,
|
| 81 |
+
roles: Array.from(document.querySelectorAll('input[name="role"]:checked')).map(input => input.value),
|
| 82 |
+
participantId: this.elements.participantInput.value.trim()
|
| 83 |
+
};
|
| 84 |
+
|
| 85 |
+
StateManager.updateProfile(profileData);
|
| 86 |
+
|
| 87 |
+
// Close welcome popup and re-enable scrolling
|
| 88 |
+
this.elements.welcomePopup.style.display = 'none';
|
| 89 |
+
document.body.classList.remove('no-scroll');
|
| 90 |
+
},
|
| 91 |
+
|
| 92 |
+
checkParticipantIdInput() {
|
| 93 |
+
const input = this.elements.participantInput;
|
| 94 |
+
// Save current cursor position
|
| 95 |
+
const start = input.selectionStart;
|
| 96 |
+
const end = input.selectionEnd;
|
| 97 |
+
|
| 98 |
+
// Remove any character that is NOT a-z, A-Z, 0-9, _, or -
|
| 99 |
+
const newValue = input.value.replace(/[^-a-zA-Z0-9_]/g, '');
|
| 100 |
+
|
| 101 |
+
// Only update if something was actually removed
|
| 102 |
+
if (input.value !== newValue) {
|
| 103 |
+
input.value = newValue;
|
| 104 |
+
// Restore cursor position so it doesn't jump to the end
|
| 105 |
+
input.setSelectionRange(start - 1, end - 1);
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
};
|
static/components/settings-component.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// components/settings-component.js - Settings modal functionality
|
| 2 |
+
|
| 3 |
+
import { StateManager } from '../services/state-manager.js';
|
| 4 |
+
import { TranslationService } from '../services/translation-service.js';
|
| 5 |
+
|
| 6 |
+
export const SettingsComponent = {
|
| 7 |
+
elements: {
|
| 8 |
+
settingsBtn: null,
|
| 9 |
+
settingsModal: null,
|
| 10 |
+
doneSettingsBtn: null,
|
| 11 |
+
closeSettingsBtn: null,
|
| 12 |
+
frRadioBtnSettings: null,
|
| 13 |
+
enRadioBtnSettings: null,
|
| 14 |
+
increaseFontSizeBtn: null,
|
| 15 |
+
decreaseFontSizeBtn: null,
|
| 16 |
+
resetFontSizeBtn: null
|
| 17 |
+
},
|
| 18 |
+
|
| 19 |
+
constants: {
|
| 20 |
+
MIN_FONT_SIZE: 0.75,
|
| 21 |
+
MAX_FONT_SIZE: 1.625,
|
| 22 |
+
FONT_SIZE_STEP: 0.125 // 1/8 rem for smooth increments
|
| 23 |
+
},
|
| 24 |
+
|
| 25 |
+
/**
|
| 26 |
+
* Initialize the settings component
|
| 27 |
+
*/
|
| 28 |
+
init() {
|
| 29 |
+
this.elements.settingsBtn = document.getElementById('settings-btn');
|
| 30 |
+
this.elements.settingsModal = document.getElementById('settings-modal');
|
| 31 |
+
this.elements.doneSettingsBtn = document.getElementById('done-settings');
|
| 32 |
+
this.elements.closeSettingsBtn = document.getElementById('close-settings-btn');
|
| 33 |
+
this.elements.frRadioBtnSettings = document.getElementById('lang-fr-settings');
|
| 34 |
+
this.elements.enRadioBtnSettings = document.getElementById('lang-en-settings');
|
| 35 |
+
this.elements.increaseFontSizeBtn = document.getElementById('increase-font-size-btn');
|
| 36 |
+
this.elements.decreaseFontSizeBtn = document.getElementById('decrease-font-size-btn');
|
| 37 |
+
this.elements.resetFontSizeBtn = document.getElementById('reset-font-size-btn');
|
| 38 |
+
|
| 39 |
+
this.attachOutsideClickListener();
|
| 40 |
+
this.attachEventListeners();
|
| 41 |
+
this.updateLanguageRadioButtons();
|
| 42 |
+
},
|
| 43 |
+
|
| 44 |
+
attachOutsideClickListener() {
|
| 45 |
+
this.elements.settingsModal.addEventListener('click', (e) => {
|
| 46 |
+
// Check if click is on the overlay itself (not its children)
|
| 47 |
+
if (e.target === this.elements.settingsModal) {
|
| 48 |
+
this.closeModal();
|
| 49 |
+
}
|
| 50 |
+
});
|
| 51 |
+
},
|
| 52 |
+
|
| 53 |
+
/**
|
| 54 |
+
* Attach event listeners
|
| 55 |
+
*/
|
| 56 |
+
attachEventListeners() {
|
| 57 |
+
this.elements.settingsBtn.addEventListener('click', (e) => this.openModal(e));
|
| 58 |
+
this.elements.closeSettingsBtn.addEventListener('click', () => this.closeModal());
|
| 59 |
+
this.elements.doneSettingsBtn.addEventListener('click', () => this.closeModal());
|
| 60 |
+
|
| 61 |
+
// Language change listeners
|
| 62 |
+
this.elements.frRadioBtnSettings.addEventListener('change', () => {
|
| 63 |
+
TranslationService.setLanguage(this.elements.frRadioBtnSettings.value);
|
| 64 |
+
this.updateLanguageRadioButtons();
|
| 65 |
+
});
|
| 66 |
+
this.elements.enRadioBtnSettings.addEventListener('change', () => {
|
| 67 |
+
TranslationService.setLanguage(this.elements.enRadioBtnSettings.value);
|
| 68 |
+
this.updateLanguageRadioButtons();
|
| 69 |
+
});
|
| 70 |
+
|
| 71 |
+
// Font size listeners
|
| 72 |
+
this.elements.increaseFontSizeBtn.addEventListener('click', () => {
|
| 73 |
+
this.updateFontSize(StateManager.fontSize + this.constants.FONT_SIZE_STEP);
|
| 74 |
+
});
|
| 75 |
+
this.elements.decreaseFontSizeBtn.addEventListener('click', () => {
|
| 76 |
+
this.updateFontSize(StateManager.fontSize - this.constants.FONT_SIZE_STEP);
|
| 77 |
+
});
|
| 78 |
+
this.elements.resetFontSizeBtn.addEventListener('click', () => {
|
| 79 |
+
this.updateFontSize(1); // 1rem = browser default
|
| 80 |
+
});
|
| 81 |
+
},
|
| 82 |
+
|
| 83 |
+
/**
|
| 84 |
+
* Open the settings modal
|
| 85 |
+
*/
|
| 86 |
+
openModal(e) {
|
| 87 |
+
e.preventDefault();
|
| 88 |
+
this.elements.settingsModal.style.display = '';
|
| 89 |
+
},
|
| 90 |
+
|
| 91 |
+
/**
|
| 92 |
+
* Close the settings modal
|
| 93 |
+
*/
|
| 94 |
+
closeModal() {
|
| 95 |
+
this.elements.settingsModal.style.display = 'none';
|
| 96 |
+
},
|
| 97 |
+
|
| 98 |
+
/**
|
| 99 |
+
* Update font size
|
| 100 |
+
* @param {number} newSize - New font size in rem
|
| 101 |
+
*/
|
| 102 |
+
updateFontSize(newSize) {
|
| 103 |
+
const clampedSize = Math.min(
|
| 104 |
+
this.constants.MAX_FONT_SIZE,
|
| 105 |
+
Math.max(this.constants.MIN_FONT_SIZE, newSize)
|
| 106 |
+
);
|
| 107 |
+
StateManager.setFontSize(clampedSize);
|
| 108 |
+
document.documentElement.style.fontSize = clampedSize + 'rem';
|
| 109 |
+
},
|
| 110 |
+
|
| 111 |
+
/**
|
| 112 |
+
* Update language radio buttons to reflect current language
|
| 113 |
+
*/
|
| 114 |
+
updateLanguageRadioButtons() {
|
| 115 |
+
this.elements.frRadioBtnSettings.checked = StateManager.currentLang === 'fr';
|
| 116 |
+
this.elements.enRadioBtnSettings.checked = StateManager.currentLang === 'en';
|
| 117 |
+
}
|
| 118 |
+
};
|
static/services/api-service.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// services/api-service.js - All API interactions
|
| 2 |
+
|
| 3 |
+
import { Utils } from '../utils.js';
|
| 4 |
+
import { StateManager } from './state-manager.js';
|
| 5 |
+
|
| 6 |
+
export const ApiService = {
|
| 7 |
+
/**
|
| 8 |
+
* Send a chat message to the server
|
| 9 |
+
* @param {string} text - User message text
|
| 10 |
+
* @param {string} modelType - Model type to use
|
| 11 |
+
* @returns {Promise<Object>} Response data
|
| 12 |
+
*/
|
| 13 |
+
async sendChatMessage(text, modelType) {
|
| 14 |
+
const payload = {
|
| 15 |
+
user_id: Utils.getMachineId(),
|
| 16 |
+
session_id: StateManager.sessionId,
|
| 17 |
+
conversation_id: StateManager.getConversationId(modelType),
|
| 18 |
+
human_message: text,
|
| 19 |
+
model_type: modelType,
|
| 20 |
+
consent: StateManager.consentGranted,
|
| 21 |
+
age_group: StateManager.profile.ageGroup,
|
| 22 |
+
gender: StateManager.profile.gender,
|
| 23 |
+
roles: StateManager.profile.roles,
|
| 24 |
+
participant_id: StateManager.profile.participantId,
|
| 25 |
+
lang: StateManager.currentLang
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
const res = await fetch('/chat', {
|
| 29 |
+
method: 'POST',
|
| 30 |
+
headers: { 'Content-Type': 'application/json' },
|
| 31 |
+
body: JSON.stringify(payload),
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
if (!res.ok) {
|
| 35 |
+
throw new Error(`HTTP ${res.status}`);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
return res;
|
| 39 |
+
},
|
| 40 |
+
|
| 41 |
+
/**
|
| 42 |
+
* Upload a file to the server
|
| 43 |
+
* @param {File} file - File to upload
|
| 44 |
+
* @returns {Promise<boolean>} Success status
|
| 45 |
+
*/
|
| 46 |
+
async uploadFile(file) {
|
| 47 |
+
const formData = new FormData();
|
| 48 |
+
formData.append('file', file);
|
| 49 |
+
formData.append('session_id', StateManager.sessionId);
|
| 50 |
+
|
| 51 |
+
try {
|
| 52 |
+
const res = await fetch('/file', {
|
| 53 |
+
method: 'PUT',
|
| 54 |
+
body: formData,
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
if (!res.ok) {
|
| 58 |
+
if (res.status === 413) {
|
| 59 |
+
showSnackbar(translations[StateManager.currentLang]["file_upload_failed_file_too_large"], 'error');
|
| 60 |
+
} else if (res.status === 400) {
|
| 61 |
+
showSnackbar(translations[StateManager.currentLang]["file_upload_failed_malformed_file"], 'error');
|
| 62 |
+
} else if (res.status === 415) {
|
| 63 |
+
showSnackbar(translations[StateManager.currentLang]["file_upload_failed_unsupported_mime_type"], 'error');
|
| 64 |
+
} else if (res.status === 419) {
|
| 65 |
+
showSnackbar(translations[StateManager.currentLang]["file_upload_failed_exceed_session_size"], 'error');
|
| 66 |
+
} else if (res.status === 500) {
|
| 67 |
+
showSnackbar(translations[StateManager.currentLang]["file_upload_failed_server_error"], 'error');
|
| 68 |
+
} else {
|
| 69 |
+
showSnackbar(translations[StateManager.currentLang]["file_upload_failed_unknown_error"], 'error');
|
| 70 |
+
}
|
| 71 |
+
return false;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
showSnackbar(translations[StateManager.currentLang]["file_upload_success"], 'success');
|
| 75 |
+
return true;
|
| 76 |
+
} catch (err) {
|
| 77 |
+
showSnackbar(translations[StateManager.currentLang]["file_upload_failed_network_error"], 'error');
|
| 78 |
+
return false;
|
| 79 |
+
}
|
| 80 |
+
},
|
| 81 |
+
|
| 82 |
+
/**
|
| 83 |
+
* Delete a file from the server
|
| 84 |
+
* @param {File} file - File to delete
|
| 85 |
+
* @returns {Promise<boolean>} Success status
|
| 86 |
+
*/
|
| 87 |
+
async deleteFile(file) {
|
| 88 |
+
const payload = {
|
| 89 |
+
file_name: file.name,
|
| 90 |
+
user_id: Utils.getMachineId(),
|
| 91 |
+
session_id: StateManager.sessionId,
|
| 92 |
+
consent: StateManager.consentGranted,
|
| 93 |
+
age_group: StateManager.profile.ageGroup,
|
| 94 |
+
gender: StateManager.profile.gender,
|
| 95 |
+
roles: StateManager.profile.roles,
|
| 96 |
+
participant_id: StateManager.profile.participantId
|
| 97 |
+
};
|
| 98 |
+
|
| 99 |
+
try {
|
| 100 |
+
const res = await fetch('/file', {
|
| 101 |
+
method: 'DELETE',
|
| 102 |
+
body: JSON.stringify(payload),
|
| 103 |
+
headers: { 'Content-Type': 'application/json' },
|
| 104 |
+
});
|
| 105 |
+
|
| 106 |
+
if (!res.ok) {
|
| 107 |
+
showSnackbar(translations[StateManager.currentLang]["file_upload_failed_server_error"], 'error');
|
| 108 |
+
return false;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
showSnackbar(translations[StateManager.currentLang]["file_delete_success"], 'success');
|
| 112 |
+
return true;
|
| 113 |
+
} catch (err) {
|
| 114 |
+
showSnackbar(translations[StateManager.currentLang]["file_delete_failed_network_error"], 'error');
|
| 115 |
+
return false;
|
| 116 |
+
}
|
| 117 |
+
},
|
| 118 |
+
|
| 119 |
+
/**
|
| 120 |
+
* Send a comment to the server
|
| 121 |
+
* @param {string} comment - Comment text
|
| 122 |
+
* @returns {Promise<Object>} Response object with status
|
| 123 |
+
*/
|
| 124 |
+
async sendComment(comment) {
|
| 125 |
+
const payload = {
|
| 126 |
+
user_id: Utils.getMachineId(),
|
| 127 |
+
session_id: StateManager.sessionId,
|
| 128 |
+
comment,
|
| 129 |
+
consent: StateManager.consentGranted,
|
| 130 |
+
age_group: StateManager.profile.ageGroup,
|
| 131 |
+
gender: StateManager.profile.gender,
|
| 132 |
+
roles: StateManager.profile.roles,
|
| 133 |
+
participant_id: StateManager.profile.participantId
|
| 134 |
+
};
|
| 135 |
+
|
| 136 |
+
try {
|
| 137 |
+
const res = await fetch('/comment', {
|
| 138 |
+
method: 'POST',
|
| 139 |
+
headers: { 'Content-Type': 'application/json' },
|
| 140 |
+
body: JSON.stringify(payload),
|
| 141 |
+
});
|
| 142 |
+
|
| 143 |
+
if (!res.ok) {
|
| 144 |
+
return {
|
| 145 |
+
success: false,
|
| 146 |
+
status: res.status
|
| 147 |
+
};
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
return {
|
| 151 |
+
success: true
|
| 152 |
+
};
|
| 153 |
+
} catch (err) {
|
| 154 |
+
return {
|
| 155 |
+
success: false,
|
| 156 |
+
error: err
|
| 157 |
+
};
|
| 158 |
+
}
|
| 159 |
+
},
|
| 160 |
+
|
| 161 |
+
/**
|
| 162 |
+
* Submit message feedback to the server
|
| 163 |
+
* @param {Object} feedbackData - Feedback data object
|
| 164 |
+
* @returns {Promise<Object>} Response object with status
|
| 165 |
+
*/
|
| 166 |
+
async submitFeedback(feedbackData) {
|
| 167 |
+
const payload = {
|
| 168 |
+
...feedbackData,
|
| 169 |
+
consent: StateManager.consentGranted,
|
| 170 |
+
age_group: StateManager.profile.ageGroup,
|
| 171 |
+
gender: StateManager.profile.gender,
|
| 172 |
+
roles: StateManager.profile.roles,
|
| 173 |
+
participant_id: StateManager.profile.participantId,
|
| 174 |
+
lang: StateManager.currentLang
|
| 175 |
+
};
|
| 176 |
+
|
| 177 |
+
try {
|
| 178 |
+
const res = await fetch('/feedback', {
|
| 179 |
+
method: 'POST',
|
| 180 |
+
headers: { 'Content-Type': 'application/json' },
|
| 181 |
+
body: JSON.stringify(payload),
|
| 182 |
+
});
|
| 183 |
+
|
| 184 |
+
if (!res.ok) {
|
| 185 |
+
return {
|
| 186 |
+
success: false,
|
| 187 |
+
status: res.status
|
| 188 |
+
};
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
return {
|
| 192 |
+
success: true
|
| 193 |
+
};
|
| 194 |
+
} catch (err) {
|
| 195 |
+
return {
|
| 196 |
+
success: false,
|
| 197 |
+
error: err
|
| 198 |
+
};
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
};
|
static/services/state-manager.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// services/state-manager.js - Central state management
|
| 2 |
+
|
| 3 |
+
import { Utils } from '../utils.js';
|
| 4 |
+
|
| 5 |
+
export const StateManager = {
|
| 6 |
+
// Session data
|
| 7 |
+
sessionId: Utils.generateSessionId(),
|
| 8 |
+
|
| 9 |
+
// User profile
|
| 10 |
+
profile: {
|
| 11 |
+
ageGroup: '',
|
| 12 |
+
gender: '',
|
| 13 |
+
roles: [],
|
| 14 |
+
participantId: ''
|
| 15 |
+
},
|
| 16 |
+
|
| 17 |
+
// Consent
|
| 18 |
+
consentGranted: false,
|
| 19 |
+
|
| 20 |
+
// Language
|
| 21 |
+
currentLang: (() => {
|
| 22 |
+
const browserLang = navigator.language.split('-')[0];
|
| 23 |
+
const defaultLang = ['en', 'fr'].includes(browserLang) ? browserLang : 'en';
|
| 24 |
+
return localStorage.getItem('preferredLang') || defaultLang;
|
| 25 |
+
})(),
|
| 26 |
+
|
| 27 |
+
// Chat data - stores messages and conversation IDs for each model
|
| 28 |
+
modelChats: {
|
| 29 |
+
"champ": {
|
| 30 |
+
messages: [],
|
| 31 |
+
conversation_id: Utils.generateConversationId()
|
| 32 |
+
},
|
| 33 |
+
"openai": {
|
| 34 |
+
messages: [],
|
| 35 |
+
conversation_id: Utils.generateConversationId()
|
| 36 |
+
},
|
| 37 |
+
"google-conservative": {
|
| 38 |
+
messages: [],
|
| 39 |
+
conversation_id: Utils.generateConversationId()
|
| 40 |
+
},
|
| 41 |
+
"google-creative": {
|
| 42 |
+
messages: [],
|
| 43 |
+
conversation_id: Utils.generateConversationId()
|
| 44 |
+
}
|
| 45 |
+
},
|
| 46 |
+
|
| 47 |
+
// File upload state
|
| 48 |
+
sessionFiles: [],
|
| 49 |
+
|
| 50 |
+
// Font size
|
| 51 |
+
fontSize: 1, // 1rem = browser default
|
| 52 |
+
|
| 53 |
+
/**
|
| 54 |
+
* Update user profile
|
| 55 |
+
* @param {Object} profileData - Profile data object
|
| 56 |
+
*/
|
| 57 |
+
updateProfile(profileData) {
|
| 58 |
+
this.profile = { ...this.profile, ...profileData };
|
| 59 |
+
},
|
| 60 |
+
|
| 61 |
+
/**
|
| 62 |
+
* Set consent status
|
| 63 |
+
* @param {boolean} granted - Whether consent is granted
|
| 64 |
+
*/
|
| 65 |
+
setConsent(granted) {
|
| 66 |
+
this.consentGranted = granted;
|
| 67 |
+
},
|
| 68 |
+
|
| 69 |
+
/**
|
| 70 |
+
* Set current language and save to localStorage
|
| 71 |
+
* @param {string} lang - Language code ('en' or 'fr')
|
| 72 |
+
*/
|
| 73 |
+
setLanguage(lang) {
|
| 74 |
+
this.currentLang = lang;
|
| 75 |
+
localStorage.setItem('preferredLang', lang);
|
| 76 |
+
},
|
| 77 |
+
|
| 78 |
+
/**
|
| 79 |
+
* Add a message to the current model's chat
|
| 80 |
+
* @param {string} modelType - The model type
|
| 81 |
+
* @param {Object} message - Message object with role and content
|
| 82 |
+
*/
|
| 83 |
+
addMessage(modelType, message) {
|
| 84 |
+
this.modelChats[modelType].messages.push(message);
|
| 85 |
+
},
|
| 86 |
+
|
| 87 |
+
/**
|
| 88 |
+
* Get messages for a specific model
|
| 89 |
+
* @param {string} modelType - The model type
|
| 90 |
+
* @returns {Array} Array of messages
|
| 91 |
+
*/
|
| 92 |
+
getMessages(modelType) {
|
| 93 |
+
return this.modelChats[modelType].messages;
|
| 94 |
+
},
|
| 95 |
+
|
| 96 |
+
/**
|
| 97 |
+
* Get conversation ID for a specific model
|
| 98 |
+
* @param {string} modelType - The model type
|
| 99 |
+
* @returns {string} Conversation ID
|
| 100 |
+
*/
|
| 101 |
+
getConversationId(modelType) {
|
| 102 |
+
return this.modelChats[modelType].conversation_id;
|
| 103 |
+
},
|
| 104 |
+
|
| 105 |
+
/**
|
| 106 |
+
* Clear conversation for a specific model
|
| 107 |
+
* @param {string} modelType - The model type
|
| 108 |
+
*/
|
| 109 |
+
clearConversation(modelType) {
|
| 110 |
+
this.modelChats[modelType].messages = [];
|
| 111 |
+
this.modelChats[modelType].conversation_id = Utils.generateConversationId();
|
| 112 |
+
},
|
| 113 |
+
|
| 114 |
+
/**
|
| 115 |
+
* Add file to session
|
| 116 |
+
* @param {File} file - File object
|
| 117 |
+
*/
|
| 118 |
+
addFile(file) {
|
| 119 |
+
this.sessionFiles.push(file);
|
| 120 |
+
},
|
| 121 |
+
|
| 122 |
+
/**
|
| 123 |
+
* Remove file from session
|
| 124 |
+
* @param {File} file - File object to remove
|
| 125 |
+
*/
|
| 126 |
+
removeFile(file) {
|
| 127 |
+
this.sessionFiles = this.sessionFiles.filter(f => f !== file);
|
| 128 |
+
},
|
| 129 |
+
|
| 130 |
+
/**
|
| 131 |
+
* Get all session files
|
| 132 |
+
* @returns {Array} Array of files
|
| 133 |
+
*/
|
| 134 |
+
getFiles() {
|
| 135 |
+
return this.sessionFiles;
|
| 136 |
+
},
|
| 137 |
+
|
| 138 |
+
/**
|
| 139 |
+
* Set font size
|
| 140 |
+
* @param {number} size - Font size in rem
|
| 141 |
+
*/
|
| 142 |
+
setFontSize(size) {
|
| 143 |
+
this.fontSize = size;
|
| 144 |
+
}
|
| 145 |
+
};
|
static/services/translation-service.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// services/translation-service.js - Translation and i18n logic
|
| 2 |
+
|
| 3 |
+
import { StateManager } from './state-manager.js';
|
| 4 |
+
|
| 5 |
+
export const TranslationService = {
|
| 6 |
+
/**
|
| 7 |
+
* Apply translations to all elements with data-i18n attribute
|
| 8 |
+
*/
|
| 9 |
+
applyTranslation() {
|
| 10 |
+
document.querySelectorAll('[data-i18n]').forEach(element => {
|
| 11 |
+
const key = element.getAttribute('data-i18n');
|
| 12 |
+
element.textContent = translations[StateManager.currentLang][key];
|
| 13 |
+
});
|
| 14 |
+
document.querySelectorAll('[data-i18n-placeholder]').forEach(element => {
|
| 15 |
+
const key = element.getAttribute('data-i18n-placeholder');
|
| 16 |
+
element.placeholder = translations[StateManager.currentLang][key];
|
| 17 |
+
});
|
| 18 |
+
document.querySelectorAll('[data-i18n-title]').forEach(element => {
|
| 19 |
+
const key = element.getAttribute('data-i18n-title');
|
| 20 |
+
element.title = translations[StateManager.currentLang][key];
|
| 21 |
+
});
|
| 22 |
+
},
|
| 23 |
+
|
| 24 |
+
/**
|
| 25 |
+
* Set the language and apply translations
|
| 26 |
+
* @param {string} lang - Language code ('en' or 'fr')
|
| 27 |
+
*/
|
| 28 |
+
setLanguage(lang) {
|
| 29 |
+
StateManager.setLanguage(lang);
|
| 30 |
+
this.applyTranslation();
|
| 31 |
+
this.updateLanguageRadioButtons();
|
| 32 |
+
},
|
| 33 |
+
|
| 34 |
+
/**
|
| 35 |
+
* Update all language radio buttons to reflect current language
|
| 36 |
+
*/
|
| 37 |
+
updateLanguageRadioButtons() {
|
| 38 |
+
const frRadioBtn = document.getElementById('lang-fr');
|
| 39 |
+
const enRadioBtn = document.getElementById('lang-en');
|
| 40 |
+
const frRadioBtnSettings = document.getElementById('lang-fr-settings');
|
| 41 |
+
const enRadioBtnSettings = document.getElementById('lang-en-settings');
|
| 42 |
+
|
| 43 |
+
if (frRadioBtn) frRadioBtn.checked = StateManager.currentLang === 'fr';
|
| 44 |
+
if (enRadioBtn) enRadioBtn.checked = StateManager.currentLang === 'en';
|
| 45 |
+
if (frRadioBtnSettings) frRadioBtnSettings.checked = StateManager.currentLang === 'fr';
|
| 46 |
+
if (enRadioBtnSettings) enRadioBtnSettings.checked = StateManager.currentLang === 'en';
|
| 47 |
+
}
|
| 48 |
+
};
|
static/styles/base.css
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Dark theme page background */
|
| 2 |
+
body {
|
| 3 |
+
margin: 0;
|
| 4 |
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
| 5 |
+
sans-serif;
|
| 6 |
+
background: #0b1020;
|
| 7 |
+
color: #f5f5f5;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
body.no-scroll {
|
| 11 |
+
overflow: hidden;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
button {
|
| 15 |
+
font-size: 1rem;
|
| 16 |
+
user-select: none;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
a {
|
| 20 |
+
color: #4da6ff;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
label, select, legend {
|
| 24 |
+
user-select: none;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
.unselectable {
|
| 28 |
+
user-select: none;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
/* SVG ICONS */
|
| 33 |
+
svg {
|
| 34 |
+
width: 16px;
|
| 35 |
+
height: 16px;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/* Spinning animation for the uploading button */
|
| 39 |
+
@keyframes spin {
|
| 40 |
+
from { transform: rotate(0deg); }
|
| 41 |
+
to { transform: rotate(360deg); }
|
| 42 |
+
}
|
| 43 |
+
.spinning {
|
| 44 |
+
animation: spin 1s linear infinite;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
/* Generic buttons */
|
| 48 |
+
.ok-button {
|
| 49 |
+
padding: 8px 18px;
|
| 50 |
+
border-radius: 10px;
|
| 51 |
+
border: none;
|
| 52 |
+
background: #4c6fff;
|
| 53 |
+
color: white;
|
| 54 |
+
font-weight: 600;
|
| 55 |
+
cursor: pointer;
|
| 56 |
+
transition: all 0.2s ease;
|
| 57 |
+
max-width: 200px;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
.ok-button:hover {
|
| 61 |
+
background: #3453e6;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.no-button {
|
| 65 |
+
padding: 8px 18px;
|
| 66 |
+
border-radius: 10px;
|
| 67 |
+
border: none;
|
| 68 |
+
background: #dc2626;
|
| 69 |
+
color: white;
|
| 70 |
+
font-weight: 600;
|
| 71 |
+
cursor: pointer;
|
| 72 |
+
transition: all 0.2s ease;
|
| 73 |
+
max-width: 200px;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
.no-button:hover {
|
| 77 |
+
background-color: #b91c1c;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
.disabled-button {
|
| 81 |
+
padding: 8px 18px;
|
| 82 |
+
border-radius: 10px;
|
| 83 |
+
border: none;
|
| 84 |
+
font-weight: 600;
|
| 85 |
+
background-color: #e5e7eb;
|
| 86 |
+
color: #9ca3af;
|
| 87 |
+
cursor: not-allowed;
|
| 88 |
+
max-width: 200px;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
.cancelBtn {
|
| 92 |
+
background: #0d132475;
|
| 93 |
+
color: #9ca3af;
|
| 94 |
+
border: 1px solid #2c3554;
|
| 95 |
+
padding: 8px 18px;
|
| 96 |
+
border-radius: 10px;
|
| 97 |
+
font-weight: 600;
|
| 98 |
+
cursor: pointer;
|
| 99 |
+
transition: all 0.2s ease;
|
| 100 |
+
max-width: 200px;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.cancelBtn:hover {
|
| 104 |
+
background: #0d1324;
|
| 105 |
+
color: #f5f5f5;
|
| 106 |
+
border-color: #4a5f8f;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.center-button {
|
| 110 |
+
text-align: center;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
/* Modals */
|
| 114 |
+
.modal {
|
| 115 |
+
/* Covers the entier view port */
|
| 116 |
+
position: fixed;
|
| 117 |
+
left: 0;
|
| 118 |
+
top: 0;
|
| 119 |
+
width: 100%;
|
| 120 |
+
height: 100%;
|
| 121 |
+
|
| 122 |
+
/* Center the content of the modal */
|
| 123 |
+
display: flex;
|
| 124 |
+
align-items: center;
|
| 125 |
+
justify-content: center;
|
| 126 |
+
|
| 127 |
+
/* Put the modal in front */
|
| 128 |
+
z-index: 1;
|
| 129 |
+
|
| 130 |
+
/* Mask what is behind the modal */
|
| 131 |
+
background-color: rgba(0, 0, 0, 0.8);
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
.modal h2 {
|
| 135 |
+
margin-top: 0;
|
| 136 |
+
margin-bottom: 0;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
/* Dark theme overlay box */
|
| 140 |
+
.modal-content {
|
| 141 |
+
/* Snap this slide to the left edge of the slider */
|
| 142 |
+
scroll-snap-align: start;
|
| 143 |
+
|
| 144 |
+
/* Center the content of the modal */
|
| 145 |
+
display: flex;
|
| 146 |
+
justify-content: flex-start;
|
| 147 |
+
|
| 148 |
+
/* Looks */
|
| 149 |
+
background: #141b2f; /* CHANGED: match theme */
|
| 150 |
+
color: #f5f5f5; /* NEW: readable on dark bg */
|
| 151 |
+
padding: 24px;
|
| 152 |
+
border-radius: 12px;
|
| 153 |
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
| 154 |
+
box-sizing: border-box;
|
| 155 |
+
margin: 0 auto;
|
| 156 |
+
|
| 157 |
+
/* Enable scrolling */
|
| 158 |
+
overflow-y: auto;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
/* Modals with slider animation */
|
| 162 |
+
.slider {
|
| 163 |
+
/* Smooth scrolling */
|
| 164 |
+
scroll-snap-type: x mandatory;
|
| 165 |
+
scroll-behavior: smooth;
|
| 166 |
+
|
| 167 |
+
/* Clip slides that are off-screen */
|
| 168 |
+
overflow-x: hidden;
|
| 169 |
+
|
| 170 |
+
/* Constrain the slider so children can scroll */
|
| 171 |
+
max-height: 90dvh;
|
| 172 |
+
|
| 173 |
+
/* Place the elements next to the others horizontally*/
|
| 174 |
+
display: flex;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.slide {
|
| 178 |
+
/* Each slide fills the full width of the slider */
|
| 179 |
+
min-width: 100%;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.modal-content.slide {
|
| 183 |
+
max-width: 400px;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.language-modal,
|
| 187 |
+
.consent-box,
|
| 188 |
+
.profile,
|
| 189 |
+
.feedback-modal {
|
| 190 |
+
display: flex;
|
| 191 |
+
flex-direction: column;
|
| 192 |
+
justify-content: space-between;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
.language-modal,
|
| 196 |
+
.consent-box {
|
| 197 |
+
max-height: 350px;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
/* Checkbox & Radio Groups */
|
| 201 |
+
.form-group {
|
| 202 |
+
margin-bottom: 1.5rem;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
label, .group-label {
|
| 206 |
+
display: block;
|
| 207 |
+
font-weight: 600;
|
| 208 |
+
margin-bottom: 0.5rem;
|
| 209 |
+
font-size: 0.95rem;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
.radio-group {
|
| 213 |
+
display: flex;
|
| 214 |
+
flex-wrap: wrap;
|
| 215 |
+
gap: 1rem;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
.checkbox-grid {
|
| 219 |
+
display: grid;
|
| 220 |
+
grid-template-columns: repeat(2, 1fr); /* Creates two equal columns */
|
| 221 |
+
gap: 12px;
|
| 222 |
+
margin-top: 8px;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
.checkbox-grid-lang {
|
| 226 |
+
display: grid;
|
| 227 |
+
grid-template-columns: repeat(1, 1fr);
|
| 228 |
+
gap: 12px;
|
| 229 |
+
margin-top: 8px;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
.checkbox-grid-lang label,
|
| 233 |
+
.checkbox-grid label {
|
| 234 |
+
font-weight: 400;
|
| 235 |
+
display: flex;
|
| 236 |
+
align-items: center;
|
| 237 |
+
gap: 10px;
|
| 238 |
+
padding: 8px;
|
| 239 |
+
border: 1px solid #eee; /* Light border makes it look like a contained element */
|
| 240 |
+
border-radius: 6px;
|
| 241 |
+
cursor: pointer;
|
| 242 |
+
transition: background 0.2s;
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
.checkbox-grid-lang label:hover,
|
| 246 |
+
.checkbox-grid label:hover {
|
| 247 |
+
background-color: #ffffff; /* The white background you wanted */
|
| 248 |
+
color: #111111; /* Forces the text to be dark/visible */
|
| 249 |
+
border-color: #007bff; /* Optional: adds a blue border to show it's active */
|
| 250 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.1); /* Optional: adds a soft depth */
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
.radio-group label, .checkbox-grid label, .checkbox-grid-lang label {
|
| 254 |
+
font-weight: 400;
|
| 255 |
+
display: flex;
|
| 256 |
+
align-items: center;
|
| 257 |
+
gap: 0.5rem;
|
| 258 |
+
cursor: pointer;
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
/* Modern Inputs */
|
| 262 |
+
input[type="checkbox"] {
|
| 263 |
+
width: 18px;
|
| 264 |
+
height: 18px;
|
| 265 |
+
cursor: pointer;
|
| 266 |
+
accent-color: #007bff; /* Modern way to color native inputs */
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
select, input[type="text"] {
|
| 270 |
+
width: 100%;
|
| 271 |
+
box-sizing: border-box;
|
| 272 |
+
padding: 12px 6px 12px 6px;
|
| 273 |
+
border: 1px solid #ddd;
|
| 274 |
+
border-radius: 8px;
|
| 275 |
+
font-size: 1rem;
|
| 276 |
+
outline: none;
|
| 277 |
+
transition: border-color 0.2s;
|
| 278 |
+
}
|
| 279 |
+
select:focus, input[type="text"]:focus {
|
| 280 |
+
border-color: #007bff;
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
/* Close button (X) */
|
| 284 |
+
.closeBtn {
|
| 285 |
+
position: absolute;
|
| 286 |
+
top: 15px;
|
| 287 |
+
right: 15px;
|
| 288 |
+
width: 28px; /* Explicit small width */
|
| 289 |
+
height: 28px; /* Explicit small height */
|
| 290 |
+
padding: 0;
|
| 291 |
+
background: transparent;
|
| 292 |
+
color: #6b7280;
|
| 293 |
+
border: none;
|
| 294 |
+
border-radius: 4px;
|
| 295 |
+
font-size: 20px;
|
| 296 |
+
line-height: 28px; /* Center the × vertically */
|
| 297 |
+
text-align: center; /* Center the × horizontally */
|
| 298 |
+
cursor: pointer;
|
| 299 |
+
transition: all 0.2s ease;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
/* RESPONSIVE DESIGN */
|
| 304 |
+
@media (max-width: 460px) {
|
| 305 |
+
/* Hide the text descriptions of the file action buttons */
|
| 306 |
+
.file-actions button span {
|
| 307 |
+
display: none;
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
/* Enlarge the chat container on mobile */
|
| 311 |
+
.chat-container {
|
| 312 |
+
margin: 0;
|
| 313 |
+
width: 100dvw;
|
| 314 |
+
height: 100dvh;
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
/* Reduce the font size of the title on mobile */
|
| 318 |
+
/* Also, add a gap between the title and the details */
|
| 319 |
+
.chat-header h1 {
|
| 320 |
+
margin: 0 0 10px 0;
|
| 321 |
+
font-size: 1.4rem;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
/* Increase the size of the modals on mobile */
|
| 325 |
+
.modal-content {
|
| 326 |
+
width: 90%;
|
| 327 |
+
}
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
@media (max-height: 720px) {
|
| 331 |
+
/* Enlarge the chat container on small screens */
|
| 332 |
+
.chat-container {
|
| 333 |
+
margin: 0;
|
| 334 |
+
width: 100dvw;
|
| 335 |
+
height: 100dvh;
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
/* Reduce the font size of the title */
|
| 339 |
+
.chat-header h1 {
|
| 340 |
+
font-size: 1.4rem;
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
/* Increase the size of the modals */
|
| 344 |
+
.modal-content {
|
| 345 |
+
width: 90%;
|
| 346 |
+
}
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
@media (min-width: 460px) {
|
| 350 |
+
details {
|
| 351 |
+
display: block;
|
| 352 |
+
}
|
| 353 |
+
details[open] {
|
| 354 |
+
display: block;
|
| 355 |
+
}
|
| 356 |
+
details summary {
|
| 357 |
+
display: none;
|
| 358 |
+
}
|
| 359 |
+
}
|
static/styles/components/chat.css
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.chat-container {
|
| 2 |
+
width: 90dvw;
|
| 3 |
+
height: 90dvh;
|
| 4 |
+
margin: 5dvh auto;
|
| 5 |
+
background: #141b2f;
|
| 6 |
+
border-radius: 16px;
|
| 7 |
+
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
|
| 8 |
+
box-sizing: border-box;
|
| 9 |
+
display: flex;
|
| 10 |
+
flex-direction: column;
|
| 11 |
+
padding: 16px;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
.chat-header {
|
| 15 |
+
padding: 0px 4px 12px;
|
| 16 |
+
border-bottom: 1px solid #2c3554;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
.chat-header h1 {
|
| 20 |
+
margin: 0;
|
| 21 |
+
font-size: 1.8rem;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
.chat-header .subtitle {
|
| 25 |
+
margin: 4px 0 0;
|
| 26 |
+
color: #c0c6e0;
|
| 27 |
+
font-size: 0.95rem;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/* Chat window */
|
| 31 |
+
.chat-window {
|
| 32 |
+
flex: 1;
|
| 33 |
+
margin-top: 10px;
|
| 34 |
+
padding: 10px;
|
| 35 |
+
overflow-y: auto;
|
| 36 |
+
background: #0d1324;
|
| 37 |
+
border-radius: 12px;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
/* Message bubbles */
|
| 41 |
+
.msg-bubble {
|
| 42 |
+
max-width: 75%;
|
| 43 |
+
padding: 8px 12px;
|
| 44 |
+
margin-bottom: 8px;
|
| 45 |
+
border-radius: 12px;
|
| 46 |
+
font-size: 0.95rem;
|
| 47 |
+
line-height: 1.4;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
.msg-bubble.user {
|
| 51 |
+
margin-left: auto;
|
| 52 |
+
background: #4c6fff;
|
| 53 |
+
color: #ffffff;
|
| 54 |
+
border-bottom-right-radius: 4px;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
.msg-bubble.assistant {
|
| 58 |
+
margin-right: auto;
|
| 59 |
+
background: #1f2840;
|
| 60 |
+
color: #f5f5f5;
|
| 61 |
+
border-bottom-left-radius: 4px;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
/* Input area */
|
| 65 |
+
.chat-input-area {
|
| 66 |
+
display: flex;
|
| 67 |
+
gap: 8px;
|
| 68 |
+
margin-top: 12px;
|
| 69 |
+
border-top: 1px solid #2c3554;
|
| 70 |
+
padding-top: 8px;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
.chat-input-container {
|
| 74 |
+
flex: 1;
|
| 75 |
+
border-radius: 10px;
|
| 76 |
+
border: 1px solid #2c3554;
|
| 77 |
+
background: #0d1324;
|
| 78 |
+
padding: 8px;
|
| 79 |
+
resize: none;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.chat-input-area textarea {
|
| 83 |
+
background: transparent;
|
| 84 |
+
border: none;
|
| 85 |
+
resize: none;
|
| 86 |
+
outline: none;
|
| 87 |
+
color: #f5f5f5;
|
| 88 |
+
font-size: 0.95rem;
|
| 89 |
+
width: 100%;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.chat-toolbar {
|
| 93 |
+
display: flex;
|
| 94 |
+
justify-content: space-between;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
/* Chat toolbar */
|
| 99 |
+
.toolbar-btn {
|
| 100 |
+
background: transparent;
|
| 101 |
+
border: none;
|
| 102 |
+
resize: none;
|
| 103 |
+
outline: none;
|
| 104 |
+
color: #f5f5f5;
|
| 105 |
+
cursor: pointer;
|
| 106 |
+
transition: background 0.2s;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.toolbar-btn {
|
| 110 |
+
/* background-color: rgba(255, 255, 255, 0.1); */
|
| 111 |
+
margin-left: auto;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
/* Status and comment text */
|
| 115 |
+
.status-comment {
|
| 116 |
+
margin-top: 6px;
|
| 117 |
+
font-size: 0.85rem;
|
| 118 |
+
|
| 119 |
+
display: flex;
|
| 120 |
+
justify-content: space-between;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
.status-info {
|
| 124 |
+
color: #ffce56;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.status-ok {
|
| 128 |
+
color: #8be48b;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
.status-error {
|
| 132 |
+
color: #ff8080;
|
| 133 |
+
}
|
static/styles/components/comment.css
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Comment area */
|
| 2 |
+
.comment-area {
|
| 3 |
+
position: relative;
|
| 4 |
+
display: flex;
|
| 5 |
+
flex-direction: column;
|
| 6 |
+
gap: 16px;
|
| 7 |
+
background: #141b2f;
|
| 8 |
+
padding: 24px;
|
| 9 |
+
border-radius: 15px;
|
| 10 |
+
border: 1px solid #2c3554;
|
| 11 |
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
| 12 |
+
width: 90%;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
.comment-area h2 {
|
| 16 |
+
margin: 0 0 8px 0;
|
| 17 |
+
font-size: 1.5rem;
|
| 18 |
+
color: #f5f5f5;
|
| 19 |
+
font-weight: 600;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
.comment-area textarea {
|
| 23 |
+
/* max-width: 425px; */
|
| 24 |
+
min-height: 120px;
|
| 25 |
+
border-radius: 10px;
|
| 26 |
+
border: 1px solid #2c3554;
|
| 27 |
+
background: #0d1324;
|
| 28 |
+
color: #f5f5f5;
|
| 29 |
+
padding: 12px;
|
| 30 |
+
resize: vertical;
|
| 31 |
+
font-size: 1rem;
|
| 32 |
+
font-family: inherit;
|
| 33 |
+
transition: border-color 0.2s ease;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.comment-area textarea:focus {
|
| 37 |
+
outline: none;
|
| 38 |
+
border-color: #4a5f8f;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
.comment-area textarea::placeholder {
|
| 42 |
+
color: #6b7280;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/* Button container */
|
| 46 |
+
.comment-area .button-group {
|
| 47 |
+
display: flex;
|
| 48 |
+
gap: 12px;
|
| 49 |
+
margin: auto;
|
| 50 |
+
}
|
static/styles/components/consent.css
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.consent-check {
|
| 2 |
+
display: flex;
|
| 3 |
+
align-items: center;
|
| 4 |
+
margin: 16px 0;
|
| 5 |
+
gap: 10px;
|
| 6 |
+
}
|
static/styles/components/feedback.css
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* styles/components/feedback.css - Message feedback buttons and modal */
|
| 2 |
+
|
| 3 |
+
/* Message container to hold bubble + feedback buttons */
|
| 4 |
+
.message-container {
|
| 5 |
+
display: flex;
|
| 6 |
+
flex-direction: column;
|
| 7 |
+
margin-bottom: 8px;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
.message-container .msg-bubble {
|
| 11 |
+
margin-bottom: 4px;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
/* Feedback buttons */
|
| 15 |
+
.feedback-buttons {
|
| 16 |
+
display: flex;
|
| 17 |
+
gap: 4px;
|
| 18 |
+
opacity: 0;
|
| 19 |
+
transition: opacity 0.2s ease;
|
| 20 |
+
margin-left: 8px;
|
| 21 |
+
align-self: flex-start;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/* Show feedback buttons on hover of the message container */
|
| 25 |
+
.message-container:hover .feedback-buttons {
|
| 26 |
+
opacity: 1;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
/* Always show if a button is active (rated) */
|
| 30 |
+
.feedback-buttons:has(.feedback-btn.active) {
|
| 31 |
+
opacity: 1;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
.feedback-btn {
|
| 35 |
+
background: rgba(255, 255, 255, 0.05);
|
| 36 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 37 |
+
border-radius: 6px;
|
| 38 |
+
padding: 4px 8px;
|
| 39 |
+
font-size: 0.9rem;
|
| 40 |
+
cursor: pointer;
|
| 41 |
+
transition: all 0.2s ease;
|
| 42 |
+
color: #c0c6e0;
|
| 43 |
+
width: 20px;
|
| 44 |
+
|
| 45 |
+
display: flex;
|
| 46 |
+
align-items: center;
|
| 47 |
+
justify-content: center;
|
| 48 |
+
|
| 49 |
+
box-sizing: content-box;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
.feedback-btn:hover {
|
| 53 |
+
background: rgba(255, 255, 255, 0.1);
|
| 54 |
+
border-color: rgba(255, 255, 255, 0.2);
|
| 55 |
+
transform: scale(1.1);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.feedback-btn.active {
|
| 59 |
+
background: rgba(76, 111, 255, 0.2);
|
| 60 |
+
border-color: #4c6fff;
|
| 61 |
+
color: #4c6fff;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
/* Copy button */
|
| 65 |
+
.copy-btn.copied {
|
| 66 |
+
background: rgba(40, 167, 69, 0.2);
|
| 67 |
+
border-color: #28a745;
|
| 68 |
+
color: #28a745;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.copy-btn.copied:hover {
|
| 72 |
+
transform: scale(1);
|
| 73 |
+
cursor: default;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
/* Feedback modal */
|
| 77 |
+
.feedback-modal {
|
| 78 |
+
position: relative;
|
| 79 |
+
width: 90%;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.feedback-modal h2 {
|
| 83 |
+
margin-bottom: 1rem;
|
| 84 |
+
color: #f5f5f5;
|
| 85 |
+
font-size: 1.3rem;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.feedback-message-preview {
|
| 89 |
+
background: rgba(13, 19, 36, 0.5);
|
| 90 |
+
border: 1px solid #2c3554;
|
| 91 |
+
border-radius: 8px;
|
| 92 |
+
margin-bottom: 1rem;
|
| 93 |
+
padding: 8px;
|
| 94 |
+
margin-top: 16px;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.feedback-label {
|
| 98 |
+
font-size: 0.85rem;
|
| 99 |
+
color: #c0c6e0;
|
| 100 |
+
margin-top: 0;
|
| 101 |
+
margin-bottom: 6px;
|
| 102 |
+
font-weight: 500;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.message-preview-text {
|
| 106 |
+
font-size: 0.9rem;
|
| 107 |
+
color: #f5f5f5;
|
| 108 |
+
line-height: 1.4;
|
| 109 |
+
max-height: 100px;
|
| 110 |
+
overflow-y: auto;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.feedback-modal .form-group {
|
| 114 |
+
margin-bottom: 1rem;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
.feedback-modal label {
|
| 118 |
+
display: block;
|
| 119 |
+
margin-bottom: 8px;
|
| 120 |
+
color: #c0c6e0;
|
| 121 |
+
font-weight: 500;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
.feedback-modal textarea {
|
| 125 |
+
width: 100%;
|
| 126 |
+
background: #0d1324;
|
| 127 |
+
border: 1px solid #2c3554;
|
| 128 |
+
border-radius: 8px;
|
| 129 |
+
padding: 10px;
|
| 130 |
+
color: #f5f5f5;
|
| 131 |
+
font-family: inherit;
|
| 132 |
+
font-size: 0.95rem;
|
| 133 |
+
resize: vertical;
|
| 134 |
+
min-height: 80px;
|
| 135 |
+
box-sizing: border-box;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.feedback-modal textarea:focus {
|
| 139 |
+
outline: none;
|
| 140 |
+
border-color: #4c6fff;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
.form-hint {
|
| 144 |
+
display: block;
|
| 145 |
+
margin-top: 6px;
|
| 146 |
+
font-size: 0.8rem;
|
| 147 |
+
color: #c0c6e0;
|
| 148 |
+
font-style: italic;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
.feedback-modal .button-group {
|
| 152 |
+
display: flex;
|
| 153 |
+
gap: 12px;
|
| 154 |
+
margin: auto;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
.feedback-modal .ok-button,
|
| 158 |
+
.feedback-modal .cancelBtn {
|
| 159 |
+
padding: 10px 20px;
|
| 160 |
+
border-radius: 8px;
|
| 161 |
+
font-weight: 500;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
.feedback-modal .ok-button {
|
| 165 |
+
background: #4c6fff;
|
| 166 |
+
color: white;
|
| 167 |
+
border: none;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
.feedback-modal .ok-button:hover {
|
| 171 |
+
background: #3d5ae6;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.feedback-modal .cancelBtn {
|
| 175 |
+
background: transparent;
|
| 176 |
+
border: 1px solid #2c3554;
|
| 177 |
+
color: #c0c6e0;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
.feedback-modal .cancelBtn:hover {
|
| 181 |
+
background: rgba(255, 255, 255, 0.05);
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
/* Responsive */
|
| 185 |
+
@media (max-width: 768px) {
|
| 186 |
+
.feedback-modal {
|
| 187 |
+
min-width: unset;
|
| 188 |
+
width: 95%;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.feedback-buttons {
|
| 192 |
+
opacity: 1; /* Always show on mobile */
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
.feedback-modal .button-group button {
|
| 196 |
+
width: 100%;
|
| 197 |
+
}
|
| 198 |
+
}
|
static/styles/components/file-upload.css
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* File upload modal */
|
| 2 |
+
.file-drop-area {
|
| 3 |
+
/* 1. Dimensions */
|
| 4 |
+
min-height: 150px;
|
| 5 |
+
|
| 6 |
+
/* 2. Layout */
|
| 7 |
+
display: flex;
|
| 8 |
+
flex-direction: column;
|
| 9 |
+
align-items: center;
|
| 10 |
+
justify-content: center;
|
| 11 |
+
|
| 12 |
+
/* 3. Appearance */
|
| 13 |
+
border: 2px dashed #444; /* Dashed line makes it look like a 'slot' */
|
| 14 |
+
border-radius: 12px;
|
| 15 |
+
background-color: #111; /* Slightly lighter than your black background */
|
| 16 |
+
color: #888;
|
| 17 |
+
cursor: pointer;
|
| 18 |
+
|
| 19 |
+
/* 4. Spacing */
|
| 20 |
+
margin-top: 20px;
|
| 21 |
+
padding: 20px;
|
| 22 |
+
|
| 23 |
+
/* 5. Text */
|
| 24 |
+
text-align: center;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
.file-drop-area.active {
|
| 28 |
+
border-color: #4285f4;
|
| 29 |
+
background-color: rgba(66, 133, 244, 0.05);
|
| 30 |
+
color: white;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
.upload-file-area {
|
| 34 |
+
position: relative;
|
| 35 |
+
max-height: 90dvh;
|
| 36 |
+
display: flex;
|
| 37 |
+
flex-direction: column;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
/* File list */
|
| 41 |
+
.file-list {
|
| 42 |
+
background-color: #111;
|
| 43 |
+
border: 1px solid black;
|
| 44 |
+
border-radius: 8px;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
.file-item {
|
| 48 |
+
display: flex;
|
| 49 |
+
align-items: center;
|
| 50 |
+
justify-content: space-between;
|
| 51 |
+
padding: 12px;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
.no-file {
|
| 55 |
+
display: flex;
|
| 56 |
+
justify-content: center;
|
| 57 |
+
padding: 16px;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
.file-actions {
|
| 61 |
+
display: flex;
|
| 62 |
+
gap: 8px;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.file-actions button {
|
| 66 |
+
display: flex;
|
| 67 |
+
gap: 6px;
|
| 68 |
+
}
|
static/styles/components/settings.css
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Settings modal */
|
| 2 |
+
.settings-button {
|
| 3 |
+
align-self: center;
|
| 4 |
+
padding: 12px 12px;
|
| 5 |
+
border-radius: 8px;
|
| 6 |
+
border: 1px solid #2c3554;
|
| 7 |
+
background: #1f2840;
|
| 8 |
+
color: #f5f5f5;
|
| 9 |
+
font-size: 0.85rem;
|
| 10 |
+
cursor: pointer;
|
| 11 |
+
margin-left: auto;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
.settings-button:hover {
|
| 15 |
+
background: #273256;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
.settings-modal-content {
|
| 19 |
+
width: 480px;
|
| 20 |
+
max-width: 95%;
|
| 21 |
+
position: relative;
|
| 22 |
+
display: flex;
|
| 23 |
+
flex-direction: column;
|
| 24 |
+
justify-content: space-between;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
/* Font size */
|
| 28 |
+
.font-size-container {
|
| 29 |
+
display: flex;
|
| 30 |
+
gap: 12px;
|
| 31 |
+
margin-top: 8px;
|
| 32 |
+
margin-bottom: 8px;
|
| 33 |
+
justify-content: center;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.font-size-btn {
|
| 37 |
+
padding: 12px 20px;
|
| 38 |
+
border-radius: 8px;
|
| 39 |
+
border: 1px solid #2c3554;
|
| 40 |
+
background: #1f2840;
|
| 41 |
+
color: #f5f5f5;
|
| 42 |
+
font-size: 1rem;
|
| 43 |
+
font-weight: 600;
|
| 44 |
+
cursor: pointer;
|
| 45 |
+
transition: all 0.2s ease;
|
| 46 |
+
min-width: 80px;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
.font-size-btn:hover {
|
| 50 |
+
background: #273256;
|
| 51 |
+
border-color: #3d4a6e;
|
| 52 |
+
transform: translateY(-1px);
|
| 53 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.font-size-btn:active {
|
| 57 |
+
transform: translateY(0);
|
| 58 |
+
}
|
static/styles/control-bar.css
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Controls bar */
|
| 2 |
+
.controls-bar {
|
| 3 |
+
display: flex;
|
| 4 |
+
flex-wrap: wrap;
|
| 5 |
+
gap: 12px;
|
| 6 |
+
padding: 8px 4px;
|
| 7 |
+
border-bottom: 1px solid #2c3554;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
.control-group {
|
| 11 |
+
display: flex;
|
| 12 |
+
align-items: center;
|
| 13 |
+
gap: 8px;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
.control-group select {
|
| 17 |
+
background: #0d1324;
|
| 18 |
+
border-radius: 8px;
|
| 19 |
+
border: 1px solid #2c3554;
|
| 20 |
+
color: #f5f5f5;
|
| 21 |
+
padding: 4px 8px;
|
| 22 |
+
font-size: 0.85rem;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
.clear-button {
|
| 26 |
+
align-self: center;
|
| 27 |
+
padding: 6px 12px;
|
| 28 |
+
border-radius: 8px;
|
| 29 |
+
border: 1px solid #2c3554;
|
| 30 |
+
background: #dc2626ba;
|
| 31 |
+
color: #f5f5f5;
|
| 32 |
+
font-size: 0.85rem;
|
| 33 |
+
cursor: pointer;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.clear-button:hover {
|
| 37 |
+
background: #dc2626;
|
| 38 |
+
}
|
static/styles/snackbar.css
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.snackbar {
|
| 2 |
+
position: fixed;
|
| 3 |
+
top: 20px;
|
| 4 |
+
right: 20px;
|
| 5 |
+
padding: 16px 20px;
|
| 6 |
+
border-radius: 8px;
|
| 7 |
+
font-size: 14px;
|
| 8 |
+
font-weight: 500;
|
| 9 |
+
color: white;
|
| 10 |
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
| 11 |
+
opacity: 0;
|
| 12 |
+
transform: translateX(400px);
|
| 13 |
+
transition: all 0.3s ease;
|
| 14 |
+
z-index: 9999;
|
| 15 |
+
max-width: 350px;
|
| 16 |
+
word-wrap: break-word;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
.snackbar.show {
|
| 20 |
+
opacity: 1;
|
| 21 |
+
transform: translateX(0);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
.snackbar-success {
|
| 25 |
+
background: #10b981;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
.snackbar-error {
|
| 29 |
+
background: #ef4444;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
.snackbar-info {
|
| 33 |
+
background: #3b82f6;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.snackbar-warning {
|
| 37 |
+
background: #f59e0b;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
/* Stack multiple snackbars */
|
| 41 |
+
.snackbar:nth-child(n+2) {
|
| 42 |
+
top: calc(20px + (70px * var(--index, 0)));
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
@media (max-width: 460px) {
|
| 46 |
+
.snackbar {
|
| 47 |
+
left: 20px;
|
| 48 |
+
right: 20px;
|
| 49 |
+
max-width: none;
|
| 50 |
+
}
|
| 51 |
+
}
|
static/translations.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
const translations = {
|
| 2 |
en: {
|
| 3 |
header: "CHAMP Model Comparison",
|
| 4 |
-
sub_header: "Talk to and compare
|
| 5 |
|
| 6 |
user_guide_label: "User guide:",
|
| 7 |
user_guide_link: "CHAMP Model Comparison – Participant Testing Guide",
|
|
@@ -14,7 +14,9 @@ const translations = {
|
|
| 14 |
conversation_cleared: "Conversation cleared. Start a new chat!",
|
| 15 |
|
| 16 |
choose_language_title: "Choose your language",
|
| 17 |
-
change_language_instructions: "You can change the language at any time
|
|
|
|
|
|
|
| 18 |
|
| 19 |
consent_title: "Before you continue",
|
| 20 |
consent_desc: "By using this demo you agree that your messages will be shared with us for processing. Do not provide sensitive or private details.",
|
|
@@ -22,7 +24,7 @@ const translations = {
|
|
| 22 |
btn_agree_continue: "Agree and Continue",
|
| 23 |
|
| 24 |
profile_title: "Profile",
|
| 25 |
-
profile_desc: "We collect this information to help us understand how different groups of users interact with the system.
|
| 26 |
select_option: "(Please select an option)",
|
| 27 |
label_age: "Age group",
|
| 28 |
label_gender: "Gender",
|
|
@@ -42,13 +44,13 @@ const translations = {
|
|
| 42 |
link_comment: "Leave a comment",
|
| 43 |
|
| 44 |
comment_title: "Leave a comment",
|
| 45 |
-
comment_placeholder: "Type your comment and click Send...",
|
| 46 |
comment_sent: "Comment sent!",
|
| 47 |
|
| 48 |
file_title: "Add a file",
|
| 49 |
file_inactivity: "Uploaded files are automatically deleted after 4 hours of inactivity.",
|
| 50 |
-
file_format: "Accepted formats: PDF, TXT, DOCX, JPG, JPEG, PNG (Max
|
| 51 |
-
file_size_limit: "The total size of all uploaded files cannot exceed
|
| 52 |
error_file_format: "Please upload a picture or a document in PDF, TXT, or DOCX format. Other file types are not supported.",
|
| 53 |
error_file_size: "File size exceeds limit. Maximum allowed: 10MB.",
|
| 54 |
error_total_file_size: "The total size of the files would exceed the maximum limit of 30 MB. Please free up space by deleting files.",
|
|
@@ -64,14 +66,42 @@ const translations = {
|
|
| 64 |
file_add_instructions_suffix: " to browse",
|
| 65 |
click: "Click",
|
| 66 |
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
file_upload_success: "File upload successful!",
|
| 70 |
|
| 71 |
-
file_delete_failed_server_error: "File deletion
|
| 72 |
-
file_delete_failed_network_error: "File deletion
|
| 73 |
file_delete_success: "File deletion successful!",
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
done_btn: "Done",
|
| 76 |
|
| 77 |
ready: "Ready",
|
|
@@ -79,18 +109,20 @@ const translations = {
|
|
| 79 |
model_changed: "Model changed",
|
| 80 |
sending: "Sending...",
|
| 81 |
no_reply: "(No reply)",
|
|
|
|
| 82 |
|
| 83 |
server_error: "Error from server",
|
| 84 |
network_error: "Network error",
|
| 85 |
|
| 86 |
btn_send: "Send",
|
|
|
|
| 87 |
btn_cancel: "Cancel",
|
| 88 |
|
| 89 |
show_more: "About this demo",
|
| 90 |
},
|
| 91 |
fr: {
|
| 92 |
header: "Comparaison de Modèles CHAMP",
|
| 93 |
-
sub_header: "Discutez avec
|
| 94 |
|
| 95 |
user_guide_label: "Guide de l'utilisateur :",
|
| 96 |
user_guide_link: "Comparaison de Modèles CHAMP – Guide de test du participant",
|
|
@@ -99,10 +131,12 @@ const translations = {
|
|
| 99 |
gemini_conservative: "Gemini-3 (Prudent)",
|
| 100 |
gemini_creative: "Gemini-3 (Créatif)",
|
| 101 |
btn_clear: "Réinitialiser",
|
| 102 |
-
conversation_cleared: "Conversation réinitialisée.
|
| 103 |
|
| 104 |
choose_language_title: "Choisissez votre langue",
|
| 105 |
-
change_language_instructions: "Vous pouvez changer la langue à tout moment
|
|
|
|
|
|
|
| 106 |
|
| 107 |
consent_title: "Avant de poursuivre",
|
| 108 |
consent_desc: "En interagissant avec cette démo, vous acceptez que vos messages soient partagés avec nous à des fins de traitement. Veillez à ne partager aucune information sensible ou privée.",
|
|
@@ -110,7 +144,7 @@ const translations = {
|
|
| 110 |
btn_agree_continue: "Accepter et continuer",
|
| 111 |
|
| 112 |
profile_title: "Profil",
|
| 113 |
-
profile_desc: "Nous collectons ces informations pour nous aider à comprendre comment différents groupes d'utilisateurs interagissent avec le système.
|
| 114 |
select_option: "(Veuillez sélectionner une option)",
|
| 115 |
label_age: "Tranche d'âge",
|
| 116 |
label_gender: "Genre",
|
|
@@ -152,14 +186,41 @@ const translations = {
|
|
| 152 |
file_add_instructions_suffix: " pour parcourir",
|
| 153 |
click: "Cliquez",
|
| 154 |
|
| 155 |
-
|
| 156 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
file_upload_success: "Téléversement du fichier réussi !",
|
| 158 |
|
| 159 |
-
file_delete_failed_server_error: "
|
| 160 |
-
file_delete_failed_network_error: "
|
| 161 |
file_delete_success: "Suppression du fichier réussie !",
|
| 162 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
done_btn: "Terminer",
|
| 164 |
|
| 165 |
ready: "Prêt",
|
|
@@ -167,11 +228,13 @@ const translations = {
|
|
| 167 |
model_changed: "Changement de modèle",
|
| 168 |
sending: "Envoi...",
|
| 169 |
no_reply: "(Aucune réponse)",
|
|
|
|
| 170 |
|
| 171 |
server_error: "Erreur du serveur",
|
| 172 |
network_error: "Erreur réseau",
|
| 173 |
|
| 174 |
btn_send: "Envoyer",
|
|
|
|
| 175 |
btn_cancel: "Annuler",
|
| 176 |
|
| 177 |
show_more: "À propos de cette démo",
|
|
|
|
| 1 |
const translations = {
|
| 2 |
en: {
|
| 3 |
header: "CHAMP Model Comparison",
|
| 4 |
+
sub_header: "Talk to different models and compare their reponses. Please remember to avoid sharing any sensitive or private details during the conversation.",
|
| 5 |
|
| 6 |
user_guide_label: "User guide:",
|
| 7 |
user_guide_link: "CHAMP Model Comparison – Participant Testing Guide",
|
|
|
|
| 14 |
conversation_cleared: "Conversation cleared. Start a new chat!",
|
| 15 |
|
| 16 |
choose_language_title: "Choose your language",
|
| 17 |
+
change_language_instructions: "You can change the language at any time in the Settings menu located in the toolbar.",
|
| 18 |
+
change_language: "Change language",
|
| 19 |
+
change_font_size: "Change font size",
|
| 20 |
|
| 21 |
consent_title: "Before you continue",
|
| 22 |
consent_desc: "By using this demo you agree that your messages will be shared with us for processing. Do not provide sensitive or private details.",
|
|
|
|
| 24 |
btn_agree_continue: "Agree and Continue",
|
| 25 |
|
| 26 |
profile_title: "Profile",
|
| 27 |
+
profile_desc: "We collect this information to help us understand how different groups of users interact with the system.",
|
| 28 |
select_option: "(Please select an option)",
|
| 29 |
label_age: "Age group",
|
| 30 |
label_gender: "Gender",
|
|
|
|
| 44 |
link_comment: "Leave a comment",
|
| 45 |
|
| 46 |
comment_title: "Leave a comment",
|
| 47 |
+
comment_placeholder: "Type your comment and press Enter or click Send...",
|
| 48 |
comment_sent: "Comment sent!",
|
| 49 |
|
| 50 |
file_title: "Add a file",
|
| 51 |
file_inactivity: "Uploaded files are automatically deleted after 4 hours of inactivity.",
|
| 52 |
+
file_format: "Accepted formats: PDF, TXT, DOCX, JPG, JPEG, PNG (Max 10 MB).",
|
| 53 |
+
file_size_limit: "The total size of all uploaded files cannot exceed 30 MB.",
|
| 54 |
error_file_format: "Please upload a picture or a document in PDF, TXT, or DOCX format. Other file types are not supported.",
|
| 55 |
error_file_size: "File size exceeds limit. Maximum allowed: 10MB.",
|
| 56 |
error_total_file_size: "The total size of the files would exceed the maximum limit of 30 MB. Please free up space by deleting files.",
|
|
|
|
| 66 |
file_add_instructions_suffix: " to browse",
|
| 67 |
click: "Click",
|
| 68 |
|
| 69 |
+
settings_title: "Settings",
|
| 70 |
+
|
| 71 |
+
file_upload_failed_server_error: "File upload failed: server error.",
|
| 72 |
+
file_upload_failed_file_too_large: "File upload failed: size exceeds 10 MB limit",
|
| 73 |
+
file_upload_failed_malformed_file: "File upload failed: file invalid",
|
| 74 |
+
file_upload_failed_unsupported_mime_type: "File upload failed: file must be in PDF, TXT, DOCX, JPEG or PNG format",
|
| 75 |
+
file_upload_failed_exceed_session_size: "File upload failed: the total size of all uploaded files exceeds 30 MB",
|
| 76 |
+
file_upload_failed_network_error: "File upload failed: network error",
|
| 77 |
+
file_upload_failed_unknown_error: "File upload failed: unknown error",
|
| 78 |
file_upload_success: "File upload successful!",
|
| 79 |
|
| 80 |
+
file_delete_failed_server_error: "File deletion failed: server error",
|
| 81 |
+
file_delete_failed_network_error: "File deletion failed: network error",
|
| 82 |
file_delete_success: "File deletion successful!",
|
| 83 |
|
| 84 |
+
copy_reply_btn: "Copy the message to clipboard",
|
| 85 |
+
feedback_like_btn: "Give positive feedback",
|
| 86 |
+
feedback_dislike_btn: "Give negative feedback",
|
| 87 |
+
feedback_mixed_btn: "Give mixed feedback",
|
| 88 |
+
|
| 89 |
+
feedback_like_title: "You liked this response",
|
| 90 |
+
feedback_dislike_title: "You disliked this response",
|
| 91 |
+
feedback_neutral_title: "You think this response could be improved",
|
| 92 |
+
|
| 93 |
+
feedback_for_message: "Message:",
|
| 94 |
+
feedback_comment_label: "Tell us why (optional)",
|
| 95 |
+
feedback_comment_placeholder: "Type your comment and press Enter or click Send...",
|
| 96 |
+
feedback_optional: "You can submit without a comment",
|
| 97 |
+
|
| 98 |
+
message_copied: "Message copied to clipboard!",
|
| 99 |
+
feedback_submitted: "Feedback submitted successfully!",
|
| 100 |
+
feedback_failed_server_error: "Feedback submission failed: server error",
|
| 101 |
+
feedback_failed_network_error: "Feedback submission failed: network error",
|
| 102 |
+
|
| 103 |
+
settings_btn: "Settings",
|
| 104 |
+
|
| 105 |
done_btn: "Done",
|
| 106 |
|
| 107 |
ready: "Ready",
|
|
|
|
| 109 |
model_changed: "Model changed",
|
| 110 |
sending: "Sending...",
|
| 111 |
no_reply: "(No reply)",
|
| 112 |
+
empty_message_error: "Message cannot be empty",
|
| 113 |
|
| 114 |
server_error: "Error from server",
|
| 115 |
network_error: "Network error",
|
| 116 |
|
| 117 |
btn_send: "Send",
|
| 118 |
+
btn_submit: "Submit",
|
| 119 |
btn_cancel: "Cancel",
|
| 120 |
|
| 121 |
show_more: "About this demo",
|
| 122 |
},
|
| 123 |
fr: {
|
| 124 |
header: "Comparaison de Modèles CHAMP",
|
| 125 |
+
sub_header: "Discutez avec différents modèles et comparez leurs réponses. Veillez à ne partager aucune information sensible ou privée durant la conversation.",
|
| 126 |
|
| 127 |
user_guide_label: "Guide de l'utilisateur :",
|
| 128 |
user_guide_link: "Comparaison de Modèles CHAMP – Guide de test du participant",
|
|
|
|
| 131 |
gemini_conservative: "Gemini-3 (Prudent)",
|
| 132 |
gemini_creative: "Gemini-3 (Créatif)",
|
| 133 |
btn_clear: "Réinitialiser",
|
| 134 |
+
conversation_cleared: "Conversation réinitialisée.",
|
| 135 |
|
| 136 |
choose_language_title: "Choisissez votre langue",
|
| 137 |
+
change_language_instructions: "Vous pouvez changer la langue à tout moment dans le menu Paramètres situé dans la barre d'outils.",
|
| 138 |
+
change_language: "Changer la langue",
|
| 139 |
+
change_font_size: "Modifier la taille de la police",
|
| 140 |
|
| 141 |
consent_title: "Avant de poursuivre",
|
| 142 |
consent_desc: "En interagissant avec cette démo, vous acceptez que vos messages soient partagés avec nous à des fins de traitement. Veillez à ne partager aucune information sensible ou privée.",
|
|
|
|
| 144 |
btn_agree_continue: "Accepter et continuer",
|
| 145 |
|
| 146 |
profile_title: "Profil",
|
| 147 |
+
profile_desc: "Nous collectons ces informations pour nous aider à comprendre comment différents groupes d'utilisateurs interagissent avec le système.",
|
| 148 |
select_option: "(Veuillez sélectionner une option)",
|
| 149 |
label_age: "Tranche d'âge",
|
| 150 |
label_gender: "Genre",
|
|
|
|
| 186 |
file_add_instructions_suffix: " pour parcourir",
|
| 187 |
click: "Cliquez",
|
| 188 |
|
| 189 |
+
settings_title: "Paramètres",
|
| 190 |
+
|
| 191 |
+
file_upload_failed_server_error: "Échec du téléversement: erreur du serveur.",
|
| 192 |
+
file_upload_failed_file_too_large: "Échec du téléversement: la taille du fichier dépasse la limite de 10 Mo",
|
| 193 |
+
file_upload_failed_malformed_file: "Échec du téléversement: le fichier est invalide",
|
| 194 |
+
file_upload_failed_unsupported_mime_type: "Échec du téléversement: le fichier doit être en format PDF, TXT, DOCX, PNG ou JPEG",
|
| 195 |
+
file_upload_failed_exceed_session_size: "Échec du téléversement: la taille totale des fichiers téléversés dépassent 30 Mo",
|
| 196 |
+
file_upload_failed_network_error: "Échec du téléversement: erreur réseau",
|
| 197 |
+
file_upload_failed_unknown_error: "Échec du téléversement: erreur inconnue",
|
| 198 |
file_upload_success: "Téléversement du fichier réussi !",
|
| 199 |
|
| 200 |
+
file_delete_failed_server_error: "Échec de la suppression: erreur du serveur",
|
| 201 |
+
file_delete_failed_network_error: "Échec de la suppression: erreur réseau",
|
| 202 |
file_delete_success: "Suppression du fichier réussie !",
|
| 203 |
|
| 204 |
+
copy_reply_btn: "Copier le message dans le presse-papiers",
|
| 205 |
+
feedback_like_btn: "Donner un retour positif",
|
| 206 |
+
feedback_dislike_btn: "Donner un retour négatif",
|
| 207 |
+
feedback_mixed_btn: "Donner un retour mixte",
|
| 208 |
+
|
| 209 |
+
feedback_like_title: "Vous aimez cette réponse",
|
| 210 |
+
feedback_dislike_title: "Vous n'aimez pas cette réponse",
|
| 211 |
+
feedback_neutral_title: "Vous pensez que cette réponse peut être améliorée",
|
| 212 |
+
feedback_for_message: "Message :",
|
| 213 |
+
feedback_comment_label: "Dites-nous pourquoi (facultatif)",
|
| 214 |
+
feedback_comment_placeholder: "Tapez votre commentaire et appuyez sur Entrée ou cliquez sur Envoyer...",
|
| 215 |
+
feedback_optional: "Vous pouvez soumettre sans commentaire",
|
| 216 |
+
|
| 217 |
+
message_copied: "Message copié dans le presse-papiers !",
|
| 218 |
+
feedback_submitted: "Retour envoyé avec succès !",
|
| 219 |
+
feedback_failed_server_error: "Échec de l'envoi du retour: erreur du serveur",
|
| 220 |
+
feedback_failed_network_error: "Échec de l'envoi du retour: erreur réseau",
|
| 221 |
+
|
| 222 |
+
settings_btn: "Paramètres",
|
| 223 |
+
|
| 224 |
done_btn: "Terminer",
|
| 225 |
|
| 226 |
ready: "Prêt",
|
|
|
|
| 228 |
model_changed: "Changement de modèle",
|
| 229 |
sending: "Envoi...",
|
| 230 |
no_reply: "(Aucune réponse)",
|
| 231 |
+
empty_message_error: "Le message ne peut pas être vide.",
|
| 232 |
|
| 233 |
server_error: "Erreur du serveur",
|
| 234 |
network_error: "Erreur réseau",
|
| 235 |
|
| 236 |
btn_send: "Envoyer",
|
| 237 |
+
btn_submit: "Soumettre",
|
| 238 |
btn_cancel: "Annuler",
|
| 239 |
|
| 240 |
show_more: "À propos de cette démo",
|
static/utils.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// utils.js - Utility functions
|
| 2 |
+
|
| 3 |
+
export const Utils = {
|
| 4 |
+
/**
|
| 5 |
+
* Get or create a unique machine ID stored in localStorage
|
| 6 |
+
* @returns {string} Machine ID
|
| 7 |
+
*/
|
| 8 |
+
getMachineId() {
|
| 9 |
+
let machineId = localStorage.getItem('MachineId');
|
| 10 |
+
|
| 11 |
+
if (!machineId) {
|
| 12 |
+
machineId = 'dev-' + crypto.randomUUID();
|
| 13 |
+
localStorage.setItem('MachineId', machineId);
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
return machineId;
|
| 17 |
+
},
|
| 18 |
+
|
| 19 |
+
/**
|
| 20 |
+
* Generate a unique session ID
|
| 21 |
+
* @returns {string} Session ID
|
| 22 |
+
*/
|
| 23 |
+
generateSessionId() {
|
| 24 |
+
return 'session-' + crypto.randomUUID();
|
| 25 |
+
},
|
| 26 |
+
|
| 27 |
+
/**
|
| 28 |
+
* Generate a unique conversation ID
|
| 29 |
+
* @returns {string} Conversation ID
|
| 30 |
+
*/
|
| 31 |
+
generateConversationId() {
|
| 32 |
+
return 'conversation-' + crypto.randomUUID();
|
| 33 |
+
},
|
| 34 |
+
|
| 35 |
+
/**
|
| 36 |
+
* Remove a file from a file input element
|
| 37 |
+
* @param {HTMLInputElement} fileInput - The file input element
|
| 38 |
+
* @param {File} fileToRemove - The file to remove
|
| 39 |
+
*/
|
| 40 |
+
removeFileFromInput(fileInput, fileToRemove) {
|
| 41 |
+
// File inputs are read-only. We have to update them
|
| 42 |
+
// by assigning a new value instead of filtering out
|
| 43 |
+
// directly files we do not want anymore.
|
| 44 |
+
const dt = new DataTransfer();
|
| 45 |
+
const { files } = fileInput;
|
| 46 |
+
|
| 47 |
+
for (let i = 0; i < files.length; i++) {
|
| 48 |
+
const file = files[i];
|
| 49 |
+
if (file !== fileToRemove) {
|
| 50 |
+
dt.items.add(file);
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
fileInput.files = dt.files;
|
| 55 |
+
}
|
| 56 |
+
};
|
templates/index.html
CHANGED
|
@@ -7,12 +7,18 @@
|
|
| 7 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 8 |
|
| 9 |
<title>CHAMP Chatbot Demo</title>
|
| 10 |
-
|
| 11 |
-
<link rel="stylesheet" href="/static/
|
| 12 |
-
<
|
| 13 |
-
<
|
| 14 |
-
<
|
| 15 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
</head>
|
| 17 |
<body class="no-scroll">
|
| 18 |
<div class="chat-container">
|
|
@@ -30,8 +36,8 @@
|
|
| 30 |
|
| 31 |
<!-- Controls bar -->
|
| 32 |
<div class="controls-bar">
|
| 33 |
-
<
|
| 34 |
-
<
|
| 35 |
<select id="systemPreset">
|
| 36 |
<option value="champ" selected>CHAMP</option>
|
| 37 |
<!-- champ is our model -->
|
|
@@ -39,14 +45,37 @@
|
|
| 39 |
<option value="google-conservative" data-i18n="gemini_conservative"></option>
|
| 40 |
<option value="google-creative" data-i18n="gemini_creative"></option>
|
| 41 |
</select>
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
-
|
| 47 |
-
<
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
</div>
|
| 51 |
</div>
|
| 52 |
|
|
@@ -117,7 +146,7 @@
|
|
| 117 |
</div>
|
| 118 |
|
| 119 |
<div class="form-group">
|
| 120 |
-
<span class="group-label" data-i18n="label_role"></span>
|
| 121 |
<div class="checkbox-grid">
|
| 122 |
<label for="role-patient"><input type="checkbox" name="role" value="patient" id="role-patient"><span data-i18n="role_patient"></span></label>
|
| 123 |
<label for="role-clinician"><input type="checkbox" name="role" value="clinician" id="role-clinician"><span data-i18n="role_clinician"></span></label>
|
|
@@ -144,6 +173,36 @@
|
|
| 144 |
<main id="chatWindow" class="chat-window">
|
| 145 |
<!-- Messages get rendered here by app.js -->
|
| 146 |
</main>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
|
| 148 |
<!-- Input area -->
|
| 149 |
<footer class="chat-input-area">
|
|
@@ -151,10 +210,11 @@
|
|
| 151 |
<textarea
|
| 152 |
id="userInput"
|
| 153 |
rows="2"
|
| 154 |
-
maxlength="
|
|
|
|
| 155 |
></textarea>
|
| 156 |
<div class="chat-toolbar">
|
| 157 |
-
<button id="upload-file-btn"
|
| 158 |
</div>
|
| 159 |
</div>
|
| 160 |
|
|
@@ -164,37 +224,42 @@
|
|
| 164 |
<!-- Status/Comment line -->
|
| 165 |
<div class="status-comment">
|
| 166 |
<span data-i18n="ready" class="status-ok" id="status"></span>
|
| 167 |
-
<span id="leave-comment"><a href="#" data-i18n="link_comment"></a></span>
|
| 168 |
</div>
|
| 169 |
|
| 170 |
<!-- Comment overlay -->
|
| 171 |
<div id="comment-overlay" class="modal" style="display:none">
|
| 172 |
<div class="modal-content comment-area">
|
| 173 |
-
<button id="closeCommentBtn" class="closeBtn"
|
| 174 |
<h2 data-i18n="comment_title"></h2>
|
| 175 |
<textarea
|
| 176 |
id="commentInput"
|
| 177 |
-
maxlength="
|
|
|
|
|
|
|
| 178 |
></textarea>
|
| 179 |
<div id="commentStatus" class="comment-status"></div>
|
| 180 |
-
<
|
| 181 |
-
|
|
|
|
|
|
|
| 182 |
</div>
|
| 183 |
</div>
|
| 184 |
|
| 185 |
<!-- Upload file overlay -->
|
| 186 |
<div id="upload-file-overlay" class="modal" style="display:none">
|
| 187 |
<div class="modal-content upload-file-area">
|
| 188 |
-
<button id="close-file-upload-btn" class="closeBtn"
|
| 189 |
<h2 data-i18n="file_title"></h2>
|
| 190 |
<p data-i18n="file_inactivity"></p>
|
| 191 |
<p data-i18n="file_format"></p>
|
|
|
|
| 192 |
<h3 data-i18n="file_list_title"></h3>
|
| 193 |
<div id="file-list" class="file-list">
|
| 194 |
<!-- No files added yet -->
|
| 195 |
</div>
|
| 196 |
<h3 data-i18n="file_add_title"></h3>
|
| 197 |
-
<div id="file-drop-zone" class="file-drop-area">
|
| 198 |
<p><span data-i18n="file_add_instructions_prefix"></span><a href="#" data-i18n="click"></a><span data-i18n="file_add_instructions_suffix"></span></p>
|
| 199 |
<input
|
| 200 |
type="file"
|
|
@@ -216,14 +281,15 @@
|
|
| 216 |
|
| 217 |
<div id="snackbar-container"></div>
|
| 218 |
|
| 219 |
-
<
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
<
|
| 224 |
-
|
| 225 |
<script src="/static/translations.js"></script>
|
| 226 |
<script src="/static/snackbar.js"></script>
|
| 227 |
-
|
|
|
|
|
|
|
| 228 |
</body>
|
| 229 |
-
</html>
|
|
|
|
| 7 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 8 |
|
| 9 |
<title>CHAMP Chatbot Demo</title>
|
| 10 |
+
|
| 11 |
+
<link rel="stylesheet" href="/static/styles/components/feedback.css" />
|
| 12 |
+
<link rel="stylesheet" href="/static/styles/components/chat.css"/>
|
| 13 |
+
<link rel="stylesheet" href="/static/styles/components/comment.css"/>
|
| 14 |
+
<link rel="stylesheet" href="/static/styles/components/consent.css"/>
|
| 15 |
+
<link rel="stylesheet" href="/static/styles/components/file-upload.css"/>
|
| 16 |
+
<link rel="stylesheet" href="/static/styles/components/settings.css"/>
|
| 17 |
+
|
| 18 |
+
<link rel="stylesheet" href="/static/styles/snackbar.css" />
|
| 19 |
+
<link rel="stylesheet" href="/static/styles/control-bar.css" />
|
| 20 |
+
<link rel="stylesheet" href="/static/styles/base.css" />
|
| 21 |
+
|
| 22 |
</head>
|
| 23 |
<body class="no-scroll">
|
| 24 |
<div class="chat-container">
|
|
|
|
| 36 |
|
| 37 |
<!-- Controls bar -->
|
| 38 |
<div class="controls-bar">
|
| 39 |
+
<fieldset class="control-group">
|
| 40 |
+
<legend for="systemPreset" data-i18n="model_selection"></legend>
|
| 41 |
<select id="systemPreset">
|
| 42 |
<option value="champ" selected>CHAMP</option>
|
| 43 |
<!-- champ is our model -->
|
|
|
|
| 45 |
<option value="google-conservative" data-i18n="gemini_conservative"></option>
|
| 46 |
<option value="google-creative" data-i18n="gemini_creative"></option>
|
| 47 |
</select>
|
| 48 |
+
<button id="clearBtn" class="clear-button" data-i18n="btn_clear"></button>
|
| 49 |
+
</fieldset>
|
| 50 |
+
|
| 51 |
+
<button id="settings-btn" class="settings-button" data-i18n-title="settings_btn">⚙️</button>
|
| 52 |
+
</div>
|
| 53 |
|
| 54 |
+
<!-- Settings overlay -->
|
| 55 |
+
<div id="settings-modal" class="modal" style="display: none;">
|
| 56 |
+
<div class="modal-content settings-modal-content">
|
| 57 |
+
<button id="close-settings-btn" class="closeBtn">×</button>
|
| 58 |
+
<h2 data-i18n="settings_title"></h2>
|
| 59 |
+
<h3 data-i18n="change_language"></h3>
|
| 60 |
+
<div class="form-group">
|
| 61 |
+
<span class="group-label" data-i18n="language"></span>
|
| 62 |
+
<div class="checkbox-grid-lang">
|
| 63 |
+
<label for="lang-fr-settings"><input type="radio" name="lang-settings" value="fr" id="lang-fr-settings"><span>Français</span></label>
|
| 64 |
+
<label for="lang-en-settings"><input type="radio" name="lang-settings" value="en" id="lang-en-settings"><span>English</span></label>
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
|
| 68 |
+
<h3 data-i18n="change_font_size"></h3>
|
| 69 |
+
<div class="font-size-container">
|
| 70 |
+
<button id="increase-font-size-btn" class="font-size-btn" style="font-size: 1.2rem;">Aa+</button>
|
| 71 |
+
<button id="reset-font-size-btn" class="font-size-btn" style="font-size: 1rem;">Aa</button>
|
| 72 |
+
<button id="decrease-font-size-btn" class="font-size-btn" style="font-size: 0.8rem;">Aa-</button>
|
| 73 |
+
</div>
|
| 74 |
+
|
| 75 |
+
<!-- div to center the button -->
|
| 76 |
+
<div class="center-button">
|
| 77 |
+
<button id="done-settings" class="ok-button" style="margin-top: 20px;" data-i18n="done_btn"></button>
|
| 78 |
+
</div>
|
| 79 |
</div>
|
| 80 |
</div>
|
| 81 |
|
|
|
|
| 146 |
</div>
|
| 147 |
|
| 148 |
<div class="form-group">
|
| 149 |
+
<span class="group-label unselectable" data-i18n="label_role"></span>
|
| 150 |
<div class="checkbox-grid">
|
| 151 |
<label for="role-patient"><input type="checkbox" name="role" value="patient" id="role-patient"><span data-i18n="role_patient"></span></label>
|
| 152 |
<label for="role-clinician"><input type="checkbox" name="role" value="clinician" id="role-clinician"><span data-i18n="role_clinician"></span></label>
|
|
|
|
| 173 |
<main id="chatWindow" class="chat-window">
|
| 174 |
<!-- Messages get rendered here by app.js -->
|
| 175 |
</main>
|
| 176 |
+
|
| 177 |
+
<!-- Reply feedback overlay -->
|
| 178 |
+
<div id="feedback-overlay" class="modal" style="display:none">
|
| 179 |
+
<div class="modal-content feedback-modal">
|
| 180 |
+
<button id="closeFeedbackBtn" class="closeBtn">×</button>
|
| 181 |
+
<!-- The title is set dynamically -->
|
| 182 |
+
<h2 id="feedbackRatingDisplay"></h2>
|
| 183 |
+
|
| 184 |
+
<div class="feedback-message-preview">
|
| 185 |
+
<p class="feedback-label" data-i18n="feedback_for_message"></p>
|
| 186 |
+
<div id="feedbackMessagePreview" class="message-preview-text"></div>
|
| 187 |
+
</div>
|
| 188 |
+
|
| 189 |
+
<div class="form-group">
|
| 190 |
+
<label for="feedbackInput" data-i18n="feedback_comment_label"></label>
|
| 191 |
+
<textarea
|
| 192 |
+
id="feedbackInput"
|
| 193 |
+
rows="20"
|
| 194 |
+
maxlength="2500"
|
| 195 |
+
data-i18n-placeholder="feedback_comment_placeholder"
|
| 196 |
+
></textarea>
|
| 197 |
+
<small class="form-hint unselectable" data-i18n="feedback_optional"></small>
|
| 198 |
+
</div>
|
| 199 |
+
|
| 200 |
+
<div class="button-group">
|
| 201 |
+
<button id="cancelFeedbackBtn" class="cancelBtn" data-i18n="btn_cancel"></button>
|
| 202 |
+
<button id="submitFeedbackBtn" class="ok-button" data-i18n="btn_send"></button>
|
| 203 |
+
</div>
|
| 204 |
+
</div>
|
| 205 |
+
</div>
|
| 206 |
|
| 207 |
<!-- Input area -->
|
| 208 |
<footer class="chat-input-area">
|
|
|
|
| 210 |
<textarea
|
| 211 |
id="userInput"
|
| 212 |
rows="2"
|
| 213 |
+
maxlength="2500"
|
| 214 |
+
data-i18n-placeholder="input_placeholder"
|
| 215 |
></textarea>
|
| 216 |
<div class="chat-toolbar">
|
| 217 |
+
<button id="upload-file-btn" class="toolbar-btn" data-i18n="btn_add_file"></button>
|
| 218 |
</div>
|
| 219 |
</div>
|
| 220 |
|
|
|
|
| 224 |
<!-- Status/Comment line -->
|
| 225 |
<div class="status-comment">
|
| 226 |
<span data-i18n="ready" class="status-ok" id="status"></span>
|
| 227 |
+
<span id="leave-comment" class="unselectable"><a href="#" data-i18n="link_comment"></a></span>
|
| 228 |
</div>
|
| 229 |
|
| 230 |
<!-- Comment overlay -->
|
| 231 |
<div id="comment-overlay" class="modal" style="display:none">
|
| 232 |
<div class="modal-content comment-area">
|
| 233 |
+
<button id="closeCommentBtn" class="closeBtn">×</button>
|
| 234 |
<h2 data-i18n="comment_title"></h2>
|
| 235 |
<textarea
|
| 236 |
id="commentInput"
|
| 237 |
+
maxlength="2500"
|
| 238 |
+
data-i18n-placeholder="comment_placeholder"
|
| 239 |
+
rows="20"
|
| 240 |
></textarea>
|
| 241 |
<div id="commentStatus" class="comment-status"></div>
|
| 242 |
+
<div class="button-group">
|
| 243 |
+
<button id="cancelCommentBtn" class="cancelBtn" data-i18n="btn_cancel"></button>
|
| 244 |
+
<button id="sendCommentBtn" class="ok-button" data-i18n="btn_send"></button>
|
| 245 |
+
</div>
|
| 246 |
</div>
|
| 247 |
</div>
|
| 248 |
|
| 249 |
<!-- Upload file overlay -->
|
| 250 |
<div id="upload-file-overlay" class="modal" style="display:none">
|
| 251 |
<div class="modal-content upload-file-area">
|
| 252 |
+
<button id="close-file-upload-btn" class="closeBtn">×</button>
|
| 253 |
<h2 data-i18n="file_title"></h2>
|
| 254 |
<p data-i18n="file_inactivity"></p>
|
| 255 |
<p data-i18n="file_format"></p>
|
| 256 |
+
<p data-i18n="file_size_limit"></p>
|
| 257 |
<h3 data-i18n="file_list_title"></h3>
|
| 258 |
<div id="file-list" class="file-list">
|
| 259 |
<!-- No files added yet -->
|
| 260 |
</div>
|
| 261 |
<h3 data-i18n="file_add_title"></h3>
|
| 262 |
+
<div id="file-drop-zone" class="file-drop-area unselectable">
|
| 263 |
<p><span data-i18n="file_add_instructions_prefix"></span><a href="#" data-i18n="click"></a><span data-i18n="file_add_instructions_suffix"></span></p>
|
| 264 |
<input
|
| 265 |
type="file"
|
|
|
|
| 281 |
|
| 282 |
<div id="snackbar-container"></div>
|
| 283 |
|
| 284 |
+
<!-- External dependencies -->
|
| 285 |
+
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
| 286 |
+
<script src="https://cdn.jsdelivr.net/npm/dompurify@2.4.2/dist/purify.min.js"></script>
|
| 287 |
+
|
| 288 |
+
<!-- Global dependencies (non-module) -->
|
|
|
|
| 289 |
<script src="/static/translations.js"></script>
|
| 290 |
<script src="/static/snackbar.js"></script>
|
| 291 |
+
|
| 292 |
+
<!-- Main application (ES6 module) -->
|
| 293 |
+
<script type="module" src="/static/app.js"></script>
|
| 294 |
</body>
|
| 295 |
+
</html>
|
tests/api/conftest.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# tests/api/conftest.py
|
| 2 |
+
import pytest
|
| 3 |
+
from main import limiter
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@pytest.fixture(autouse=True)
|
| 7 |
+
def disable_rate_limit(request):
|
| 8 |
+
"""Disable rate limiting for all tests"""
|
| 9 |
+
|
| 10 |
+
# Do not disable the rate limiter if the test is marked with enable_rate_limit
|
| 11 |
+
if "enable_rate_limit" in request.keywords:
|
| 12 |
+
yield
|
| 13 |
+
else:
|
| 14 |
+
limiter.enabled = False
|
| 15 |
+
yield
|
| 16 |
+
limiter.enabled = True
|
tests/api/test_chat_post.py
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from fastapi.testclient import TestClient
|
| 3 |
+
from unittest.mock import Mock, patch
|
| 4 |
+
from main import app
|
| 5 |
+
|
| 6 |
+
client = TestClient(app)
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class TestChatEndpoint:
|
| 10 |
+
"""Test the POST /chat endpoint"""
|
| 11 |
+
|
| 12 |
+
@pytest.fixture
|
| 13 |
+
def base_required_fields(self):
|
| 14 |
+
"""Base fields required by IdentifierBase and ProfileBase"""
|
| 15 |
+
return {
|
| 16 |
+
"user_id": "test-user-123",
|
| 17 |
+
"participant_id": "participant-456",
|
| 18 |
+
"session_id": "test-session-123",
|
| 19 |
+
"consent": True,
|
| 20 |
+
"age_group": "25-34",
|
| 21 |
+
"gender": "M",
|
| 22 |
+
"roles": ["patient"],
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
@pytest.fixture
|
| 26 |
+
def valid_payload(self, base_required_fields):
|
| 27 |
+
return {
|
| 28 |
+
**base_required_fields,
|
| 29 |
+
"conversation_id": "conversation-abc",
|
| 30 |
+
"model_type": "champ",
|
| 31 |
+
"lang": "en",
|
| 32 |
+
"human_message": "What should I do about a fever?",
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
@pytest.fixture
|
| 36 |
+
def mock_dependencies(self):
|
| 37 |
+
"""Mock all external dependencies"""
|
| 38 |
+
with (
|
| 39 |
+
patch("main.session_tracker") as mock_tracker,
|
| 40 |
+
patch("main.PIIFilter") as mock_pii_class,
|
| 41 |
+
patch("main.session_conversation_store") as mock_conv_store,
|
| 42 |
+
patch("main.session_document_store") as mock_doc_store,
|
| 43 |
+
patch("main.call_llm") as mock_call_llm,
|
| 44 |
+
patch("main.log_event") as mock_log_event,
|
| 45 |
+
):
|
| 46 |
+
# Setup PIIFilter
|
| 47 |
+
mock_pii = Mock()
|
| 48 |
+
mock_pii.sanitize.return_value = "sanitized message"
|
| 49 |
+
mock_pii_class.return_value = mock_pii
|
| 50 |
+
|
| 51 |
+
# Setup conversation store
|
| 52 |
+
mock_conv_store.add_human_message.return_value = [
|
| 53 |
+
Mock(role="user", content="sanitized message")
|
| 54 |
+
]
|
| 55 |
+
|
| 56 |
+
# Setup document store
|
| 57 |
+
mock_doc_store.get_document_contents.return_value = None
|
| 58 |
+
|
| 59 |
+
# Setup call_llm (non-streaming by default)
|
| 60 |
+
mock_call_llm.return_value = ("AI response", {}, [])
|
| 61 |
+
|
| 62 |
+
yield {
|
| 63 |
+
"tracker": mock_tracker,
|
| 64 |
+
"pii_filter": mock_pii,
|
| 65 |
+
"conv_store": mock_conv_store,
|
| 66 |
+
"doc_store": mock_doc_store,
|
| 67 |
+
"call_llm": mock_call_llm,
|
| 68 |
+
"log_event": mock_log_event,
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
# ==================== Successful Chat Tests ====================
|
| 72 |
+
|
| 73 |
+
def test_chat_success_non_streaming(self, valid_payload, mock_dependencies):
|
| 74 |
+
"""Test successful non-streaming chat response"""
|
| 75 |
+
response = client.post("/chat", json=valid_payload)
|
| 76 |
+
|
| 77 |
+
assert response.status_code == 200
|
| 78 |
+
assert response.json() == {"reply": "AI response"}
|
| 79 |
+
|
| 80 |
+
def test_chat_updates_session_tracker(self, valid_payload, mock_dependencies):
|
| 81 |
+
"""Test that session tracker is updated"""
|
| 82 |
+
client.post("/chat", json=valid_payload)
|
| 83 |
+
|
| 84 |
+
mock_dependencies["tracker"].update_session.assert_called_once_with(
|
| 85 |
+
"test-session-123"
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
def test_chat_sanitizes_message(self, valid_payload, mock_dependencies):
|
| 89 |
+
"""Test that PII filter is applied to message"""
|
| 90 |
+
client.post("/chat", json=valid_payload)
|
| 91 |
+
|
| 92 |
+
mock_dependencies["pii_filter"].sanitize.assert_called_once_with(
|
| 93 |
+
"What should I do about a fever?"
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
def test_chat_adds_human_message_to_store(self, valid_payload, mock_dependencies):
|
| 97 |
+
"""Test that sanitized message is added to conversation store"""
|
| 98 |
+
client.post("/chat", json=valid_payload)
|
| 99 |
+
|
| 100 |
+
mock_dependencies["conv_store"].add_human_message.assert_called_once_with(
|
| 101 |
+
"test-session-123", "conversation-abc", "sanitized message"
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
def test_chat_retrieves_documents(self, valid_payload, mock_dependencies):
|
| 105 |
+
"""Test that documents are retrieved from document store"""
|
| 106 |
+
client.post("/chat", json=valid_payload)
|
| 107 |
+
|
| 108 |
+
mock_dependencies["doc_store"].get_document_contents.assert_called_once_with(
|
| 109 |
+
"test-session-123"
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
def test_chat_calls_llm_with_correct_params(self, valid_payload, mock_dependencies):
|
| 113 |
+
"""Test that call_llm is invoked with correct parameters"""
|
| 114 |
+
mock_conversation = [Mock()]
|
| 115 |
+
mock_dependencies[
|
| 116 |
+
"conv_store"
|
| 117 |
+
].add_human_message.return_value = mock_conversation
|
| 118 |
+
mock_dependencies["doc_store"].get_document_contents.return_value = ["doc1"]
|
| 119 |
+
|
| 120 |
+
client.post("/chat", json=valid_payload)
|
| 121 |
+
|
| 122 |
+
# call_llm is wrapped in run_in_executor, so we need to wait
|
| 123 |
+
# The test client handles this synchronously
|
| 124 |
+
mock_dependencies["call_llm"].assert_called_once_with(
|
| 125 |
+
"champ", "en", mock_conversation, ["doc1"]
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
def test_chat_adds_assistant_reply_to_store(self, valid_payload, mock_dependencies):
|
| 129 |
+
"""Test that assistant reply is added to conversation store"""
|
| 130 |
+
client.post("/chat", json=valid_payload)
|
| 131 |
+
|
| 132 |
+
mock_dependencies["conv_store"].add_assistant_reply.assert_called_once_with(
|
| 133 |
+
"test-session-123", "conversation-abc", "AI response"
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
# ==================== Streaming Response Tests ====================
|
| 137 |
+
|
| 138 |
+
def test_chat_streaming_response(self, valid_payload, mock_dependencies):
|
| 139 |
+
"""Test streaming response from OpenAI"""
|
| 140 |
+
|
| 141 |
+
async def mock_stream():
|
| 142 |
+
yield "Hello "
|
| 143 |
+
yield "world"
|
| 144 |
+
|
| 145 |
+
mock_dependencies["call_llm"].return_value = mock_stream()
|
| 146 |
+
|
| 147 |
+
response = client.post("/chat", json=valid_payload)
|
| 148 |
+
|
| 149 |
+
assert response.status_code == 200
|
| 150 |
+
# StreamingResponse returns chunks
|
| 151 |
+
content = response.content.decode()
|
| 152 |
+
assert "Hello world" in content
|
| 153 |
+
|
| 154 |
+
# ==================== Different Model Types Tests ====================
|
| 155 |
+
|
| 156 |
+
def test_chat_openai_model(self, base_required_fields, mock_dependencies):
|
| 157 |
+
"""Test chat with OpenAI model"""
|
| 158 |
+
payload = {
|
| 159 |
+
**base_required_fields,
|
| 160 |
+
"conversation_id": "conv-1",
|
| 161 |
+
"model_type": "openai",
|
| 162 |
+
"lang": "en",
|
| 163 |
+
"human_message": "Hello",
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
# OpenAI returns AsyncGenerator
|
| 167 |
+
async def mock_stream():
|
| 168 |
+
yield "response"
|
| 169 |
+
|
| 170 |
+
mock_dependencies["call_llm"].return_value = mock_stream()
|
| 171 |
+
|
| 172 |
+
response = client.post("/chat", json=payload)
|
| 173 |
+
assert response.status_code == 200
|
| 174 |
+
|
| 175 |
+
def test_chat_google_conservative_model(
|
| 176 |
+
self, base_required_fields, mock_dependencies
|
| 177 |
+
):
|
| 178 |
+
"""Test chat with Google conservative model"""
|
| 179 |
+
payload = {
|
| 180 |
+
**base_required_fields,
|
| 181 |
+
"conversation_id": "conv-1",
|
| 182 |
+
"model_type": "google-conservative",
|
| 183 |
+
"lang": "en",
|
| 184 |
+
"human_message": "Hello",
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
mock_dependencies["call_llm"].return_value = ("Response", {}, [])
|
| 188 |
+
|
| 189 |
+
response = client.post("/chat", json=payload)
|
| 190 |
+
assert response.status_code == 200
|
| 191 |
+
assert response.json() == {"reply": "Response"}
|
| 192 |
+
|
| 193 |
+
def test_chat_google_creative_model(self, base_required_fields, mock_dependencies):
|
| 194 |
+
"""Test chat with Google creative model"""
|
| 195 |
+
payload = {
|
| 196 |
+
**base_required_fields,
|
| 197 |
+
"conversation_id": "conv-1",
|
| 198 |
+
"model_type": "google-creative",
|
| 199 |
+
"lang": "fr",
|
| 200 |
+
"human_message": "Bonjour",
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
mock_dependencies["call_llm"].return_value = ("Réponse", {}, [])
|
| 204 |
+
|
| 205 |
+
response = client.post("/chat", json=payload)
|
| 206 |
+
assert response.status_code == 200
|
| 207 |
+
assert response.json() == {"reply": "Réponse"}
|
| 208 |
+
|
| 209 |
+
# ==================== Language Tests ====================
|
| 210 |
+
|
| 211 |
+
def test_chat_french_language(self, base_required_fields, mock_dependencies):
|
| 212 |
+
"""Test chat with French language"""
|
| 213 |
+
payload = {
|
| 214 |
+
**base_required_fields,
|
| 215 |
+
"conversation_id": "conv-1",
|
| 216 |
+
"model_type": "champ",
|
| 217 |
+
"lang": "fr",
|
| 218 |
+
"human_message": "Comment allez-vous?",
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
response = client.post("/chat", json=payload)
|
| 222 |
+
assert response.status_code == 200
|
| 223 |
+
|
| 224 |
+
# ==================== Request Validation Tests ====================
|
| 225 |
+
|
| 226 |
+
def test_chat_missing_human_message(self, base_required_fields, mock_dependencies):
|
| 227 |
+
"""Test that missing human_message returns 422"""
|
| 228 |
+
payload = {
|
| 229 |
+
**base_required_fields,
|
| 230 |
+
"conversation_id": "conv-1",
|
| 231 |
+
"model_type": "champ",
|
| 232 |
+
"lang": "en",
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
response = client.post("/chat", json=payload)
|
| 236 |
+
assert response.status_code == 422
|
| 237 |
+
|
| 238 |
+
def test_chat_empty_human_message(self, base_required_fields, mock_dependencies):
|
| 239 |
+
"""Test that empty human_message is rejected"""
|
| 240 |
+
payload = {
|
| 241 |
+
**base_required_fields,
|
| 242 |
+
"conversation_id": "conv-1",
|
| 243 |
+
"model_type": "champ",
|
| 244 |
+
"lang": "en",
|
| 245 |
+
"human_message": "",
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
response = client.post("/chat", json=payload)
|
| 249 |
+
assert response.status_code == 422
|
| 250 |
+
|
| 251 |
+
def test_chat_invalid_model_type(self, base_required_fields, mock_dependencies):
|
| 252 |
+
"""Test that invalid model_type is rejected"""
|
| 253 |
+
payload = {
|
| 254 |
+
**base_required_fields,
|
| 255 |
+
"conversation_id": "conv-1",
|
| 256 |
+
"model_type": "invalid-model",
|
| 257 |
+
"lang": "en",
|
| 258 |
+
"human_message": "Hello",
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
response = client.post("/chat", json=payload)
|
| 262 |
+
assert response.status_code == 422
|
| 263 |
+
|
| 264 |
+
def test_chat_invalid_language(self, base_required_fields, mock_dependencies):
|
| 265 |
+
"""Test that invalid language is rejected"""
|
| 266 |
+
payload = {
|
| 267 |
+
**base_required_fields,
|
| 268 |
+
"conversation_id": "conv-1",
|
| 269 |
+
"model_type": "champ",
|
| 270 |
+
"lang": "es", # Not in Literal["en", "fr"]
|
| 271 |
+
"human_message": "Hello",
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
response = client.post("/chat", json=payload)
|
| 275 |
+
assert response.status_code == 422
|
| 276 |
+
|
| 277 |
+
def test_chat_message_too_long(self, base_required_fields, mock_dependencies):
|
| 278 |
+
"""Test that message exceeding MAX_MESSAGE_LENGTH is rejected"""
|
| 279 |
+
payload = {
|
| 280 |
+
**base_required_fields,
|
| 281 |
+
"conversation_id": "conv-1",
|
| 282 |
+
"model_type": "champ",
|
| 283 |
+
"lang": "en",
|
| 284 |
+
"human_message": "x" * 100000, # Assuming this exceeds limit
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
response = client.post("/chat", json=payload)
|
| 288 |
+
assert response.status_code == 422
|
| 289 |
+
|
| 290 |
+
def test_chat_sanitizes_html_in_message(
|
| 291 |
+
self, base_required_fields, mock_dependencies
|
| 292 |
+
):
|
| 293 |
+
"""Test that HTML tags are removed from human_message"""
|
| 294 |
+
payload = {
|
| 295 |
+
**base_required_fields,
|
| 296 |
+
"conversation_id": "conv-1",
|
| 297 |
+
"model_type": "champ",
|
| 298 |
+
"lang": "en",
|
| 299 |
+
"human_message": "<script>alert('xss')</script>Hello",
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
response = client.post("/chat", json=payload)
|
| 303 |
+
# Should succeed with sanitized message
|
| 304 |
+
assert response.status_code == 200
|
| 305 |
+
|
| 306 |
+
def test_chat_invalid_conversation_id(
|
| 307 |
+
self, base_required_fields, mock_dependencies
|
| 308 |
+
):
|
| 309 |
+
"""Test that invalid conversation_id is rejected"""
|
| 310 |
+
payload = {
|
| 311 |
+
**base_required_fields,
|
| 312 |
+
"conversation_id": "invalid@id!",
|
| 313 |
+
"model_type": "champ",
|
| 314 |
+
"lang": "en",
|
| 315 |
+
"human_message": "Hello",
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
response = client.post("/chat", json=payload)
|
| 319 |
+
assert response.status_code == 422
|
| 320 |
+
|
| 321 |
+
# ==================== Rate Limiting Tests ====================
|
| 322 |
+
|
| 323 |
+
@pytest.mark.enable_rate_limit
|
| 324 |
+
def test_chat_rate_limiting(self, valid_payload, mock_dependencies):
|
| 325 |
+
"""Test that rate limiting works (20 requests per minute)"""
|
| 326 |
+
from fastapi.testclient import TestClient
|
| 327 |
+
from main import app
|
| 328 |
+
|
| 329 |
+
rate_limit_client = TestClient(app)
|
| 330 |
+
|
| 331 |
+
# Make 21 rapid requests
|
| 332 |
+
responses = []
|
| 333 |
+
for i in range(21):
|
| 334 |
+
response = rate_limit_client.post("/chat", json=valid_payload)
|
| 335 |
+
responses.append(response)
|
| 336 |
+
|
| 337 |
+
# 21st should be rate limited
|
| 338 |
+
assert responses[-1].status_code == 429
|
| 339 |
+
|
| 340 |
+
# ==================== Integration Tests ====================
|
| 341 |
+
|
| 342 |
+
def test_chat_full_workflow(self, valid_payload, mock_dependencies):
|
| 343 |
+
"""Test complete chat workflow"""
|
| 344 |
+
mock_conversation = [Mock(role="user", content="sanitized message")]
|
| 345 |
+
mock_dependencies[
|
| 346 |
+
"conv_store"
|
| 347 |
+
].add_human_message.return_value = mock_conversation
|
| 348 |
+
mock_dependencies["doc_store"].get_document_contents.return_value = ["doc1"]
|
| 349 |
+
mock_dependencies["call_llm"].return_value = (
|
| 350 |
+
"Full response",
|
| 351 |
+
{"key": "value"},
|
| 352 |
+
["ctx"],
|
| 353 |
+
)
|
| 354 |
+
|
| 355 |
+
response = client.post("/chat", json=valid_payload)
|
| 356 |
+
|
| 357 |
+
assert response.status_code == 200
|
| 358 |
+
assert response.json() == {"reply": "Full response"}
|
| 359 |
+
|
| 360 |
+
# Verify workflow order
|
| 361 |
+
mock_dependencies["tracker"].update_session.assert_called_once()
|
| 362 |
+
mock_dependencies["pii_filter"].sanitize.assert_called_once()
|
| 363 |
+
mock_dependencies["conv_store"].add_human_message.assert_called_once()
|
| 364 |
+
mock_dependencies["doc_store"].get_document_contents.assert_called_once()
|
| 365 |
+
mock_dependencies["call_llm"].assert_called_once()
|
| 366 |
+
mock_dependencies["conv_store"].add_assistant_reply.assert_called_once()
|
| 367 |
+
|
| 368 |
+
def test_chat_with_documents(self, valid_payload, mock_dependencies):
|
| 369 |
+
"""Test chat when user has uploaded documents"""
|
| 370 |
+
mock_dependencies["doc_store"].get_document_contents.return_value = [
|
| 371 |
+
"Document content 1",
|
| 372 |
+
"Document content 2",
|
| 373 |
+
]
|
| 374 |
+
|
| 375 |
+
response = client.post("/chat", json=valid_payload)
|
| 376 |
+
|
| 377 |
+
assert response.status_code == 200
|
| 378 |
+
# TODO
|
| 379 |
+
# Documents should be passed to call_llm
|
| 380 |
+
|
| 381 |
+
def test_chat_multiple_messages_same_conversation(
|
| 382 |
+
self, base_required_fields, mock_dependencies
|
| 383 |
+
):
|
| 384 |
+
"""Test multiple messages in same conversation"""
|
| 385 |
+
payload1 = {
|
| 386 |
+
**base_required_fields,
|
| 387 |
+
"conversation_id": "conv-1",
|
| 388 |
+
"model_type": "champ",
|
| 389 |
+
"lang": "en",
|
| 390 |
+
"human_message": "First message",
|
| 391 |
+
}
|
| 392 |
+
payload2 = {
|
| 393 |
+
**base_required_fields,
|
| 394 |
+
"conversation_id": "conv-1",
|
| 395 |
+
"model_type": "champ",
|
| 396 |
+
"lang": "en",
|
| 397 |
+
"human_message": "Second message",
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
response1 = client.post("/chat", json=payload1)
|
| 401 |
+
response2 = client.post("/chat", json=payload2)
|
| 402 |
+
|
| 403 |
+
assert response1.status_code == 200
|
| 404 |
+
assert response2.status_code == 200
|
| 405 |
+
|
| 406 |
+
def test_chat_different_conversations_same_session(
|
| 407 |
+
self, base_required_fields, mock_dependencies
|
| 408 |
+
):
|
| 409 |
+
"""Test different conversations in same session"""
|
| 410 |
+
payload1 = {
|
| 411 |
+
**base_required_fields,
|
| 412 |
+
"conversation_id": "conv-1",
|
| 413 |
+
"model_type": "champ",
|
| 414 |
+
"lang": "en",
|
| 415 |
+
"human_message": "Message in conv 1",
|
| 416 |
+
}
|
| 417 |
+
payload2 = {
|
| 418 |
+
**base_required_fields,
|
| 419 |
+
"conversation_id": "conv-2",
|
| 420 |
+
"model_type": "champ",
|
| 421 |
+
"lang": "en",
|
| 422 |
+
"human_message": "Message in conv 2",
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
response1 = client.post("/chat", json=payload1)
|
| 426 |
+
response2 = client.post("/chat", json=payload2)
|
| 427 |
+
|
| 428 |
+
assert response1.status_code == 200
|
| 429 |
+
assert response2.status_code == 200
|
| 430 |
+
|
| 431 |
+
# ==================== Edge Cases ====================
|
| 432 |
+
|
| 433 |
+
def test_chat_special_characters_in_message(
|
| 434 |
+
self, base_required_fields, mock_dependencies
|
| 435 |
+
):
|
| 436 |
+
"""Test message with special characters"""
|
| 437 |
+
payload = {
|
| 438 |
+
**base_required_fields,
|
| 439 |
+
"conversation_id": "conv-1",
|
| 440 |
+
"model_type": "champ",
|
| 441 |
+
"lang": "en",
|
| 442 |
+
"human_message": "Hello! 你好 🎉 @#$%",
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
response = client.post("/chat", json=payload)
|
| 446 |
+
assert response.status_code == 200
|
| 447 |
+
|
| 448 |
+
def test_chat_multiline_message(self, base_required_fields, mock_dependencies):
|
| 449 |
+
"""Test message with newlines"""
|
| 450 |
+
payload = {
|
| 451 |
+
**base_required_fields,
|
| 452 |
+
"conversation_id": "conv-1",
|
| 453 |
+
"model_type": "champ",
|
| 454 |
+
"lang": "en",
|
| 455 |
+
"human_message": "Line 1\nLine 2\nLine 3",
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
response = client.post("/chat", json=payload)
|
| 459 |
+
assert response.status_code == 200
|
| 460 |
+
|
| 461 |
+
def test_chat_empty_reply_from_llm(self, valid_payload, mock_dependencies):
|
| 462 |
+
"""Test handling of empty reply from LLM"""
|
| 463 |
+
mock_dependencies["call_llm"].return_value = ("", {}, [])
|
| 464 |
+
|
| 465 |
+
response = client.post("/chat", json=valid_payload)
|
| 466 |
+
assert response.status_code == 200
|
| 467 |
+
assert response.json() == {"reply": ""}
|
tests/api/test_comment_post.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from fastapi.testclient import TestClient
|
| 3 |
+
from unittest.mock import patch, Mock
|
| 4 |
+
from constants import MAX_COMMENT_LENGTH
|
| 5 |
+
from main import app # Adjust import based on your structure
|
| 6 |
+
|
| 7 |
+
client = TestClient(app)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class TestCommentEndpoint:
|
| 11 |
+
"""Test the POST /comment endpoint"""
|
| 12 |
+
|
| 13 |
+
@pytest.fixture
|
| 14 |
+
def base_required_fields(self):
|
| 15 |
+
"""Base fields required by IdentifierBase and ProfileBase"""
|
| 16 |
+
return {
|
| 17 |
+
"user_id": "test-user-123",
|
| 18 |
+
"participant_id": "participant-456",
|
| 19 |
+
"session_id": "test-session-123",
|
| 20 |
+
"consent": True,
|
| 21 |
+
"age_group": "25-34",
|
| 22 |
+
"gender": "M",
|
| 23 |
+
"roles": ["patient"],
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
@pytest.fixture
|
| 27 |
+
def valid_payload(self, base_required_fields):
|
| 28 |
+
return {**base_required_fields, "comment": "This is a test comment"}
|
| 29 |
+
|
| 30 |
+
# ==================== Successful Comment Tests ====================
|
| 31 |
+
|
| 32 |
+
def test_comment_success(self, valid_payload):
|
| 33 |
+
"""Test successful comment submission"""
|
| 34 |
+
with patch("main.log_event") as mock_log_event:
|
| 35 |
+
response = client.post("/comment", json=valid_payload)
|
| 36 |
+
|
| 37 |
+
assert response.status_code == 200
|
| 38 |
+
|
| 39 |
+
def test_comment_with_long_text(self, base_required_fields):
|
| 40 |
+
"""Test comment with very long text"""
|
| 41 |
+
payload = {**base_required_fields, "comment": "xb" * MAX_COMMENT_LENGTH}
|
| 42 |
+
|
| 43 |
+
response = client.post("/comment", json=payload)
|
| 44 |
+
|
| 45 |
+
assert response.status_code == 422
|
| 46 |
+
|
| 47 |
+
def test_comment_with_special_characters(self, base_required_fields):
|
| 48 |
+
"""Test comment with special characters and unicode"""
|
| 49 |
+
payload = {
|
| 50 |
+
**base_required_fields,
|
| 51 |
+
"comment": "Test with special chars: @#$%^&*() 你好 🎉\n\tNew line",
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
response = client.post("/comment", json=payload)
|
| 55 |
+
|
| 56 |
+
assert response.status_code == 200
|
| 57 |
+
|
| 58 |
+
def test_comment_with_multiline_text(self, base_required_fields):
|
| 59 |
+
"""Test comment with multiple lines"""
|
| 60 |
+
payload = {**base_required_fields, "comment": "Line 1\nLine 2\nLine 3"}
|
| 61 |
+
|
| 62 |
+
response = client.post("/comment", json=payload)
|
| 63 |
+
|
| 64 |
+
assert response.status_code == 200
|
| 65 |
+
|
| 66 |
+
# ==================== Empty Comment Tests ====================
|
| 67 |
+
|
| 68 |
+
def test_empty_comment_string(self, base_required_fields):
|
| 69 |
+
"""Test that empty string comment returns 400"""
|
| 70 |
+
payload = {**base_required_fields, "comment": ""}
|
| 71 |
+
|
| 72 |
+
response = client.post("/comment", json=payload)
|
| 73 |
+
|
| 74 |
+
assert response.status_code == 422
|
| 75 |
+
|
| 76 |
+
def test_whitespace_only_comment(self, base_required_fields):
|
| 77 |
+
"""Test comment with only whitespace"""
|
| 78 |
+
payload = {**base_required_fields, "comment": " "}
|
| 79 |
+
|
| 80 |
+
# Depends on how backend validates - might be accepted or rejected
|
| 81 |
+
response = client.post("/comment", json=payload)
|
| 82 |
+
|
| 83 |
+
assert response.status_code == 200
|
| 84 |
+
|
| 85 |
+
def test_missing_comment_field(self, base_required_fields):
|
| 86 |
+
"""Test that missing comment field returns validation error"""
|
| 87 |
+
payload = {**base_required_fields}
|
| 88 |
+
|
| 89 |
+
response = client.post("/comment", json=payload)
|
| 90 |
+
|
| 91 |
+
assert response.status_code == 422
|
| 92 |
+
|
| 93 |
+
# ==================== Request Validation Tests ====================
|
| 94 |
+
|
| 95 |
+
def test_missing_required_profile_fields(self, base_required_fields):
|
| 96 |
+
"""Test that missing required fields returns 422"""
|
| 97 |
+
payload = {**base_required_fields, "comment": "Test comment"}
|
| 98 |
+
del payload["consent"]
|
| 99 |
+
|
| 100 |
+
response = client.post("/comment", json=payload)
|
| 101 |
+
|
| 102 |
+
assert response.status_code == 422
|
| 103 |
+
|
| 104 |
+
def test_invalid_age_group(self, base_required_fields):
|
| 105 |
+
"""Test that invalid age group returns 422"""
|
| 106 |
+
payload = {
|
| 107 |
+
**base_required_fields,
|
| 108 |
+
"age_group": "invalid",
|
| 109 |
+
"comment": "Test comment",
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
response = client.post("/comment", json=payload)
|
| 113 |
+
|
| 114 |
+
assert response.status_code == 422
|
| 115 |
+
|
| 116 |
+
def test_invalid_gender(self, base_required_fields):
|
| 117 |
+
"""Test that invalid gender returns 422"""
|
| 118 |
+
payload = {**base_required_fields, "gender": "X", "comment": "Test comment"}
|
| 119 |
+
|
| 120 |
+
response = client.post("/comment", json=payload)
|
| 121 |
+
|
| 122 |
+
assert response.status_code == 422
|
| 123 |
+
|
| 124 |
+
def test_invalid_roles(self, base_required_fields):
|
| 125 |
+
"""Test that invalid roles return 422"""
|
| 126 |
+
payload = {
|
| 127 |
+
**base_required_fields,
|
| 128 |
+
"roles": ["invalid-role"],
|
| 129 |
+
"comment": "Test comment",
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
response = client.post("/comment", json=payload)
|
| 133 |
+
|
| 134 |
+
assert response.status_code == 422
|
| 135 |
+
|
| 136 |
+
def test_empty_roles(self, base_required_fields):
|
| 137 |
+
"""Test that empty roles set returns 422"""
|
| 138 |
+
payload = {**base_required_fields, "roles": [], "comment": "Test comment"}
|
| 139 |
+
|
| 140 |
+
response = client.post("/comment", json=payload)
|
| 141 |
+
|
| 142 |
+
assert response.status_code == 422
|
| 143 |
+
|
| 144 |
+
# ==================== Background Task Tests ====================
|
| 145 |
+
|
| 146 |
+
def test_background_task_receives_correct_data(self, valid_payload):
|
| 147 |
+
"""Test that background task is called with correct data structure"""
|
| 148 |
+
with patch("main.BackgroundTasks.add_task") as mock_add_task:
|
| 149 |
+
response = client.post("/comment", json=valid_payload)
|
| 150 |
+
|
| 151 |
+
assert response.status_code == 200
|
| 152 |
+
|
| 153 |
+
# Verify add_task was called
|
| 154 |
+
mock_add_task.assert_called_once()
|
| 155 |
+
|
| 156 |
+
# Check the arguments passed to add_task
|
| 157 |
+
call_args = mock_add_task.call_args
|
| 158 |
+
|
| 159 |
+
# First arg should be log_event function
|
| 160 |
+
# Kwargs should contain the data
|
| 161 |
+
assert "user_id" in call_args.kwargs
|
| 162 |
+
assert "session_id" in call_args.kwargs
|
| 163 |
+
assert "data" in call_args.kwargs
|
| 164 |
+
|
| 165 |
+
data = call_args.kwargs["data"]
|
| 166 |
+
assert data["comment"] == "This is a test comment"
|
| 167 |
+
assert data["consent"] == True
|
| 168 |
+
assert data["age_group"] == "25-34"
|
| 169 |
+
|
| 170 |
+
def test_different_comment_contents(self, base_required_fields):
|
| 171 |
+
"""Test various comment contents are accepted"""
|
| 172 |
+
comments = [
|
| 173 |
+
"Short",
|
| 174 |
+
"A longer comment with multiple words and punctuation!",
|
| 175 |
+
"123456789",
|
| 176 |
+
"Mixed 123 content with numbers",
|
| 177 |
+
]
|
| 178 |
+
|
| 179 |
+
for comment_text in comments:
|
| 180 |
+
payload = {**base_required_fields, "comment": comment_text}
|
| 181 |
+
|
| 182 |
+
response = client.post("/comment", json=payload)
|
| 183 |
+
assert response.status_code == 200
|
| 184 |
+
|
| 185 |
+
# ==================== Rate Limiting Tests ====================
|
| 186 |
+
|
| 187 |
+
@pytest.mark.enable_rate_limit
|
| 188 |
+
def test_rate_limiting(self, valid_payload):
|
| 189 |
+
"""Test that rate limiting works (20 requests per minute)"""
|
| 190 |
+
from fastapi.testclient import TestClient
|
| 191 |
+
from main import app
|
| 192 |
+
|
| 193 |
+
rate_limit_client = TestClient(app)
|
| 194 |
+
|
| 195 |
+
# Make 21 rapid requests
|
| 196 |
+
responses = []
|
| 197 |
+
for i in range(21):
|
| 198 |
+
response = rate_limit_client.post("/comment", json=valid_payload)
|
| 199 |
+
responses.append(response)
|
| 200 |
+
|
| 201 |
+
# 21st should be rate limited
|
| 202 |
+
assert responses[-1].status_code == 429
|
| 203 |
+
|
| 204 |
+
# ==================== Integration Tests ====================
|
| 205 |
+
|
| 206 |
+
def test_multiple_comments_same_session(self, base_required_fields):
|
| 207 |
+
"""Test submitting multiple comments from same session"""
|
| 208 |
+
comments = ["First comment", "Second comment", "Third comment"]
|
| 209 |
+
|
| 210 |
+
for comment_text in comments:
|
| 211 |
+
payload = {**base_required_fields, "comment": comment_text}
|
| 212 |
+
|
| 213 |
+
response = client.post("/comment", json=payload)
|
| 214 |
+
assert response.status_code == 200
|
| 215 |
+
|
| 216 |
+
def test_comments_from_different_sessions(self, base_required_fields):
|
| 217 |
+
"""Test comments from different sessions"""
|
| 218 |
+
sessions = ["session-1", "session-2", "session-3"]
|
| 219 |
+
|
| 220 |
+
for session_id in sessions:
|
| 221 |
+
payload = {
|
| 222 |
+
**base_required_fields,
|
| 223 |
+
"session_id": session_id,
|
| 224 |
+
"comment": f"Comment from {session_id}",
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
response = client.post("/comment", json=payload)
|
| 228 |
+
assert response.status_code == 200
|
| 229 |
+
|
| 230 |
+
def test_comment_none_value(self, base_required_fields):
|
| 231 |
+
"""Test that null/None comment is handled"""
|
| 232 |
+
payload = {**base_required_fields, "comment": None}
|
| 233 |
+
|
| 234 |
+
response = client.post("/comment", json=payload)
|
| 235 |
+
|
| 236 |
+
# Should return 400 or 422 depending on validation
|
| 237 |
+
assert response.status_code == 422
|
tests/api/test_feedback_post.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from fastapi.testclient import TestClient
|
| 3 |
+
from unittest.mock import patch
|
| 4 |
+
from constants import MAX_COMMENT_LENGTH, MAX_RESPONSE_LENGTH
|
| 5 |
+
from main import app
|
| 6 |
+
|
| 7 |
+
client = TestClient(app)
|
| 8 |
+
|
| 9 |
+
class TestFeedbackEndpoint:
|
| 10 |
+
"""Consolidated tests for POST /feedback"""
|
| 11 |
+
|
| 12 |
+
@pytest.fixture
|
| 13 |
+
def base_payload(self):
|
| 14 |
+
"""Standard valid payload structure"""
|
| 15 |
+
return {
|
| 16 |
+
"user_id": "test-user-123",
|
| 17 |
+
"participant_id": "participant-456",
|
| 18 |
+
"session_id": "test-session-123",
|
| 19 |
+
"consent": True,
|
| 20 |
+
"age_group": "25-34",
|
| 21 |
+
"gender": "M",
|
| 22 |
+
"roles": ["patient"],
|
| 23 |
+
"message_index": 5,
|
| 24 |
+
"rating": "like",
|
| 25 |
+
"reply_content": "Helpful response",
|
| 26 |
+
"comment": "Clear advice"
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
# ==================== Logic & Happy Path ====================
|
| 30 |
+
|
| 31 |
+
def test_feedback_success_and_logging(self, base_payload):
|
| 32 |
+
"""Tests the full happy path and ensures background tasks/logging are triggered"""
|
| 33 |
+
with patch("main.log_event") as mock_log, \
|
| 34 |
+
patch("main.BackgroundTasks.add_task") as mock_task:
|
| 35 |
+
|
| 36 |
+
response = client.post("/feedback", json=base_payload)
|
| 37 |
+
|
| 38 |
+
assert response.status_code == 200
|
| 39 |
+
assert mock_task.called
|
| 40 |
+
|
| 41 |
+
@pytest.mark.parametrize("rating", ["like", "dislike", "mixed"])
|
| 42 |
+
def test_valid_ratings(self, base_payload, rating):
|
| 43 |
+
"""Consolidated: Tests all valid rating strings"""
|
| 44 |
+
base_payload["rating"] = rating
|
| 45 |
+
response = client.post("/feedback", json=base_payload)
|
| 46 |
+
assert response.status_code == 200
|
| 47 |
+
|
| 48 |
+
def test_comment_optionality(self, base_payload):
|
| 49 |
+
"""Tests that comment can be empty but must exist as a key"""
|
| 50 |
+
base_payload["comment"] = ""
|
| 51 |
+
response = client.post("/feedback", json=base_payload)
|
| 52 |
+
assert response.status_code == 200
|
| 53 |
+
|
| 54 |
+
# ==================== Integer Constraints (The New Fixes) ====================
|
| 55 |
+
|
| 56 |
+
@pytest.mark.parametrize("index, expected_status", [
|
| 57 |
+
(0, 200), # Lower boundary
|
| 58 |
+
(10000, 200), # Upper boundary
|
| 59 |
+
(-1, 422), # Out of bounds (low)
|
| 60 |
+
(10001, 422), # Out of bounds (high)
|
| 61 |
+
])
|
| 62 |
+
def test_message_index_constraints(self, base_payload, index, expected_status):
|
| 63 |
+
"""Verifies ge=0 and le=10000 constraints"""
|
| 64 |
+
base_payload["message_index"] = index
|
| 65 |
+
response = client.post("/feedback", json=base_payload)
|
| 66 |
+
assert response.status_code == expected_status
|
| 67 |
+
|
| 68 |
+
# ==================== String & Security ====================
|
| 69 |
+
|
| 70 |
+
def test_html_sanitization(self, base_payload):
|
| 71 |
+
"""Ensures XSS tags are stripped (Relies on nh3 in your model)"""
|
| 72 |
+
base_payload["comment"] = "<script>alert('xss')</script>Safe Text"
|
| 73 |
+
# We assume 200 here; the real check would be inspecting the DB/Log
|
| 74 |
+
# to ensure the tags were removed.
|
| 75 |
+
response = client.post("/feedback", json=base_payload)
|
| 76 |
+
assert response.status_code == 200
|
| 77 |
+
|
| 78 |
+
@pytest.mark.parametrize("field, length", [
|
| 79 |
+
("comment", MAX_COMMENT_LENGTH + 1),
|
| 80 |
+
("reply_content", MAX_RESPONSE_LENGTH + 1),
|
| 81 |
+
])
|
| 82 |
+
def test_string_max_lengths(self, base_payload, field, length):
|
| 83 |
+
"""Verifies length constraints for strings"""
|
| 84 |
+
base_payload[field] = "x" * length
|
| 85 |
+
response = client.post("/feedback", json=base_payload)
|
| 86 |
+
assert response.status_code == 422
|
| 87 |
+
|
| 88 |
+
# ==================== Rate Limiting ====================
|
| 89 |
+
|
| 90 |
+
@pytest.mark.enable_rate_limit
|
| 91 |
+
def test_feedback_rate_limiting(self, base_payload):
|
| 92 |
+
"""Verifies the 20 requests per minute limit"""
|
| 93 |
+
# Create a fresh client to ensure limit starts at 0
|
| 94 |
+
with TestClient(app) as limit_client:
|
| 95 |
+
for _ in range(20):
|
| 96 |
+
limit_client.post("/feedback", json=base_payload)
|
| 97 |
+
|
| 98 |
+
over_limit_response = limit_client.post("/feedback", json=base_payload)
|
| 99 |
+
assert over_limit_response.status_code == 429
|
tests/api/test_file_delete.py
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from fastapi.testclient import TestClient
|
| 3 |
+
from unittest.mock import patch
|
| 4 |
+
from constants import MAX_FILE_NAME_LENGTH
|
| 5 |
+
from main import app # Adjust import based on your structure
|
| 6 |
+
|
| 7 |
+
client = TestClient(app)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def delete_with_body(url: str, json_data: dict):
|
| 11 |
+
"""Helper to send DELETE request with JSON body"""
|
| 12 |
+
return client.request("DELETE", url, json=json_data)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class TestDeleteFileEndpoint:
|
| 16 |
+
"""Test the DELETE /file endpoint"""
|
| 17 |
+
|
| 18 |
+
@pytest.fixture
|
| 19 |
+
def base_required_fields(self):
|
| 20 |
+
"""Base fields required by IdentifierBase and ProfileBase"""
|
| 21 |
+
return {
|
| 22 |
+
"user_id": "test-user-123",
|
| 23 |
+
"participant_id": "participant-456",
|
| 24 |
+
"session_id": "test-session-123",
|
| 25 |
+
"consent": True,
|
| 26 |
+
"age_group": "25-34",
|
| 27 |
+
"gender": "M",
|
| 28 |
+
"roles": ["patient"],
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
@pytest.fixture
|
| 32 |
+
def valid_payload(self, base_required_fields):
|
| 33 |
+
return {**base_required_fields, "file_name": "document.txt"}
|
| 34 |
+
|
| 35 |
+
@pytest.fixture
|
| 36 |
+
def mock_dependencies(self):
|
| 37 |
+
"""Mock external dependencies"""
|
| 38 |
+
with (
|
| 39 |
+
patch("main.session_document_store") as mock_store,
|
| 40 |
+
patch("main.replace_spaces_in_filename") as mock_replace,
|
| 41 |
+
):
|
| 42 |
+
# Setup default behavior
|
| 43 |
+
mock_replace.side_effect = lambda x: (
|
| 44 |
+
x
|
| 45 |
+
) # Return filename unchanged by default
|
| 46 |
+
mock_store.delete_document.return_value = True
|
| 47 |
+
|
| 48 |
+
yield {"store": mock_store, "replace_spaces": mock_replace}
|
| 49 |
+
|
| 50 |
+
# ==================== Successful Deletion Tests ====================
|
| 51 |
+
|
| 52 |
+
def test_successful_file_deletion(self, valid_payload, mock_dependencies):
|
| 53 |
+
"""Test successful file deletion with valid inputs"""
|
| 54 |
+
response = delete_with_body("/file", valid_payload)
|
| 55 |
+
|
| 56 |
+
assert response.status_code == 200
|
| 57 |
+
|
| 58 |
+
# Verify workflow
|
| 59 |
+
mock_dependencies["replace_spaces"].assert_called_once_with("document.txt")
|
| 60 |
+
mock_dependencies["store"].delete_document.assert_called_once_with(
|
| 61 |
+
"test-session-123", "document.txt"
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
def test_delete_file_with_spaces_in_filename(
|
| 65 |
+
self, base_required_fields, mock_dependencies
|
| 66 |
+
):
|
| 67 |
+
"""Test that spaces in filename are replaced"""
|
| 68 |
+
payload = {**base_required_fields, "file_name": "my document.txt"}
|
| 69 |
+
|
| 70 |
+
# Mock replace_spaces to return expected result
|
| 71 |
+
mock_dependencies["replace_spaces"].side_effect = None
|
| 72 |
+
mock_dependencies["replace_spaces"].return_value = "my_document.txt"
|
| 73 |
+
|
| 74 |
+
response = delete_with_body("/file", payload)
|
| 75 |
+
|
| 76 |
+
assert response.status_code == 200
|
| 77 |
+
mock_dependencies["replace_spaces"].assert_called_once_with("my document.txt")
|
| 78 |
+
mock_dependencies["store"].delete_document.assert_called_once_with(
|
| 79 |
+
"test-session-123", "my_document.txt"
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
def test_delete_file_different_filenames(
|
| 83 |
+
self, base_required_fields, mock_dependencies
|
| 84 |
+
):
|
| 85 |
+
"""Test deleting files with various filename formats"""
|
| 86 |
+
filenames = [
|
| 87 |
+
"document.txt",
|
| 88 |
+
"report.pdf",
|
| 89 |
+
"data.csv",
|
| 90 |
+
"file_with_underscores.docx",
|
| 91 |
+
"file-with-dashes.xlsx",
|
| 92 |
+
"file.multiple.dots.txt",
|
| 93 |
+
]
|
| 94 |
+
|
| 95 |
+
for filename in filenames:
|
| 96 |
+
payload = {**base_required_fields, "file_name": filename}
|
| 97 |
+
|
| 98 |
+
response = delete_with_body("/file", payload)
|
| 99 |
+
assert response.status_code == 200
|
| 100 |
+
|
| 101 |
+
def test_delete_file_different_session_ids(
|
| 102 |
+
self, base_required_fields, mock_dependencies
|
| 103 |
+
):
|
| 104 |
+
"""Test deleting files from different sessions"""
|
| 105 |
+
session_ids = [
|
| 106 |
+
"session-1",
|
| 107 |
+
"session-2",
|
| 108 |
+
"session_abc_123",
|
| 109 |
+
"a1b2c3",
|
| 110 |
+
]
|
| 111 |
+
|
| 112 |
+
for session_id in session_ids:
|
| 113 |
+
payload = {
|
| 114 |
+
**base_required_fields,
|
| 115 |
+
"session_id": session_id,
|
| 116 |
+
"file_name": "document.txt",
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
response = delete_with_body("/file", payload)
|
| 120 |
+
assert response.status_code == 200
|
| 121 |
+
|
| 122 |
+
# ==================== Request Validation Tests ====================
|
| 123 |
+
|
| 124 |
+
def test_delete_file_missing_session_id(
|
| 125 |
+
self, base_required_fields, mock_dependencies
|
| 126 |
+
):
|
| 127 |
+
"""Test that missing session_id returns validation error"""
|
| 128 |
+
payload = {**base_required_fields, "file_name": "document.txt"}
|
| 129 |
+
del payload["session_id"]
|
| 130 |
+
|
| 131 |
+
response = delete_with_body("/file", payload)
|
| 132 |
+
|
| 133 |
+
assert response.status_code == 422
|
| 134 |
+
# Store should not be called
|
| 135 |
+
assert not mock_dependencies["store"].delete_document.called
|
| 136 |
+
|
| 137 |
+
def test_delete_file_missing_file_name(
|
| 138 |
+
self, base_required_fields, mock_dependencies
|
| 139 |
+
):
|
| 140 |
+
"""Test that missing file_name returns validation error"""
|
| 141 |
+
payload = {**base_required_fields}
|
| 142 |
+
|
| 143 |
+
response = delete_with_body("/file", payload)
|
| 144 |
+
|
| 145 |
+
assert response.status_code == 422
|
| 146 |
+
assert not mock_dependencies["store"].delete_document.called
|
| 147 |
+
|
| 148 |
+
def test_delete_file_missing_both_fields(self, mock_dependencies):
|
| 149 |
+
"""Test that missing session_id and file_name returns validation error"""
|
| 150 |
+
payload = {
|
| 151 |
+
"user_id": "test-user",
|
| 152 |
+
"participant_id": "participant-123",
|
| 153 |
+
"consent": True,
|
| 154 |
+
"age_group": "25-34",
|
| 155 |
+
"gender": "M",
|
| 156 |
+
"roles": ["patient"],
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
response = delete_with_body("/file", payload)
|
| 160 |
+
|
| 161 |
+
assert response.status_code == 422
|
| 162 |
+
assert not mock_dependencies["store"].delete_document.called
|
| 163 |
+
|
| 164 |
+
def test_delete_file_empty_session_id(
|
| 165 |
+
self, base_required_fields, mock_dependencies
|
| 166 |
+
):
|
| 167 |
+
"""Test handling of empty session_id"""
|
| 168 |
+
payload = {
|
| 169 |
+
**base_required_fields,
|
| 170 |
+
"session_id": "",
|
| 171 |
+
"file_name": "document.txt",
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
response = delete_with_body("/file", payload)
|
| 175 |
+
|
| 176 |
+
# Empty string violates pattern and min_length
|
| 177 |
+
assert response.status_code == 422
|
| 178 |
+
|
| 179 |
+
def test_delete_file_empty_file_name(self, base_required_fields, mock_dependencies):
|
| 180 |
+
"""Test handling of empty file_name"""
|
| 181 |
+
payload = {**base_required_fields, "file_name": ""}
|
| 182 |
+
|
| 183 |
+
response = delete_with_body("/file", payload)
|
| 184 |
+
|
| 185 |
+
# Empty string violates min_length=1
|
| 186 |
+
assert response.status_code == 422
|
| 187 |
+
|
| 188 |
+
def test_delete_file_extra_fields_ignored(
|
| 189 |
+
self, base_required_fields, mock_dependencies
|
| 190 |
+
):
|
| 191 |
+
"""Test that extra fields in payload are ignored"""
|
| 192 |
+
payload = {
|
| 193 |
+
**base_required_fields,
|
| 194 |
+
"file_name": "document.txt",
|
| 195 |
+
"extra_field": "should be ignored",
|
| 196 |
+
"another_field": 123,
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
response = delete_with_body("/file", payload)
|
| 200 |
+
|
| 201 |
+
assert response.status_code == 200
|
| 202 |
+
mock_dependencies["store"].delete_document.assert_called_once()
|
| 203 |
+
|
| 204 |
+
# ==================== Store Behavior Tests ====================
|
| 205 |
+
|
| 206 |
+
def test_delete_file_store_returns_true(self, valid_payload, mock_dependencies):
|
| 207 |
+
"""Test when store successfully deletes (returns True)"""
|
| 208 |
+
mock_dependencies["store"].delete_document.return_value = True
|
| 209 |
+
|
| 210 |
+
response = delete_with_body("/file", valid_payload)
|
| 211 |
+
|
| 212 |
+
assert response.status_code == 200
|
| 213 |
+
|
| 214 |
+
def test_delete_file_store_returns_false(self, valid_payload, mock_dependencies):
|
| 215 |
+
"""Test when store deletion fails (returns False)"""
|
| 216 |
+
mock_dependencies["store"].delete_document.return_value = False
|
| 217 |
+
|
| 218 |
+
response = delete_with_body("/file", valid_payload)
|
| 219 |
+
|
| 220 |
+
# Endpoint doesn't check return value, so still 200
|
| 221 |
+
assert response.status_code == 200
|
| 222 |
+
|
| 223 |
+
def test_delete_file_nonexistent_file(
|
| 224 |
+
self, base_required_fields, mock_dependencies
|
| 225 |
+
):
|
| 226 |
+
"""Test deleting a file that doesn't exist"""
|
| 227 |
+
payload = {**base_required_fields, "file_name": "nonexistent.txt"}
|
| 228 |
+
|
| 229 |
+
# Store returns False for nonexistent file
|
| 230 |
+
mock_dependencies["store"].delete_document.return_value = False
|
| 231 |
+
|
| 232 |
+
response = delete_with_body("/file", payload)
|
| 233 |
+
|
| 234 |
+
# Endpoint still returns 200 (idempotent DELETE)
|
| 235 |
+
assert response.status_code == 200
|
| 236 |
+
|
| 237 |
+
def test_delete_file_nonexistent_session(
|
| 238 |
+
self, base_required_fields, mock_dependencies
|
| 239 |
+
):
|
| 240 |
+
"""Test deleting from a session that doesn't exist"""
|
| 241 |
+
payload = {
|
| 242 |
+
**base_required_fields,
|
| 243 |
+
"session_id": "nonexistent-session",
|
| 244 |
+
"file_name": "document.txt",
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
mock_dependencies["store"].delete_document.return_value = False
|
| 248 |
+
|
| 249 |
+
response = delete_with_body("/file", payload)
|
| 250 |
+
|
| 251 |
+
assert response.status_code == 200
|
| 252 |
+
|
| 253 |
+
# ==================== Filename Replacement Tests ====================
|
| 254 |
+
|
| 255 |
+
def test_replace_spaces_called_with_correct_argument(
|
| 256 |
+
self, base_required_fields, mock_dependencies
|
| 257 |
+
):
|
| 258 |
+
"""Test that replace_spaces_in_filename is called with the right argument"""
|
| 259 |
+
payload = {**base_required_fields, "file_name": "my file.txt"}
|
| 260 |
+
|
| 261 |
+
delete_with_body("/file", payload)
|
| 262 |
+
|
| 263 |
+
mock_dependencies["replace_spaces"].assert_called_once_with("my file.txt")
|
| 264 |
+
|
| 265 |
+
# ==================== Rate Limiting Tests ====================
|
| 266 |
+
|
| 267 |
+
def test_invalid_filename_pattern_double_dots(
|
| 268 |
+
self, base_required_fields, mock_dependencies
|
| 269 |
+
):
|
| 270 |
+
"""Test that filenames with double dots are rejected"""
|
| 271 |
+
payload = {**base_required_fields, "file_name": "file..txt"}
|
| 272 |
+
|
| 273 |
+
response = delete_with_body("/file", payload)
|
| 274 |
+
assert response.status_code == 422
|
| 275 |
+
|
| 276 |
+
def test_invalid_filename_pattern_starting_dot(
|
| 277 |
+
self, base_required_fields, mock_dependencies
|
| 278 |
+
):
|
| 279 |
+
"""Test that filenames starting with dot are rejected"""
|
| 280 |
+
payload = {**base_required_fields, "file_name": ".hidden.txt"}
|
| 281 |
+
|
| 282 |
+
response = delete_with_body("/file", payload)
|
| 283 |
+
assert response.status_code == 422
|
| 284 |
+
|
| 285 |
+
def test_invalid_filename_pattern_starting_space(
|
| 286 |
+
self, base_required_fields, mock_dependencies
|
| 287 |
+
):
|
| 288 |
+
"""Test that filenames starting with space are rejected"""
|
| 289 |
+
payload = {**base_required_fields, "file_name": " file.txt"}
|
| 290 |
+
|
| 291 |
+
response = delete_with_body("/file", payload)
|
| 292 |
+
assert response.status_code == 422
|
| 293 |
+
|
| 294 |
+
def test_valid_filename_with_parentheses(
|
| 295 |
+
self, base_required_fields, mock_dependencies
|
| 296 |
+
):
|
| 297 |
+
"""Test that filenames with parentheses are accepted"""
|
| 298 |
+
payload = {**base_required_fields, "file_name": "file(1).txt"}
|
| 299 |
+
|
| 300 |
+
response = delete_with_body("/file", payload)
|
| 301 |
+
assert response.status_code == 200
|
| 302 |
+
|
| 303 |
+
def test_invalid_session_id_with_special_chars(
|
| 304 |
+
self, base_required_fields, mock_dependencies
|
| 305 |
+
):
|
| 306 |
+
"""Test that session IDs with invalid characters are rejected"""
|
| 307 |
+
invalid_ids = ["session@123", "session.123", "session/123", "session 123"]
|
| 308 |
+
|
| 309 |
+
for invalid_id in invalid_ids:
|
| 310 |
+
payload = {
|
| 311 |
+
**base_required_fields,
|
| 312 |
+
"session_id": invalid_id,
|
| 313 |
+
"file_name": "document.txt",
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
response = delete_with_body("/file", payload)
|
| 317 |
+
assert response.status_code == 422
|
| 318 |
+
|
| 319 |
+
def test_invalid_age_group(self, base_required_fields, mock_dependencies):
|
| 320 |
+
"""Test that invalid age groups are rejected"""
|
| 321 |
+
payload = {
|
| 322 |
+
**base_required_fields,
|
| 323 |
+
"age_group": "99-100", # Invalid
|
| 324 |
+
"file_name": "document.txt",
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
response = delete_with_body("/file", payload)
|
| 328 |
+
assert response.status_code == 422
|
| 329 |
+
|
| 330 |
+
def test_invalid_gender(self, base_required_fields, mock_dependencies):
|
| 331 |
+
"""Test that invalid gender values are rejected"""
|
| 332 |
+
payload = {
|
| 333 |
+
**base_required_fields,
|
| 334 |
+
"gender": "X", # Invalid - must be M or F
|
| 335 |
+
"file_name": "document.txt",
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
response = delete_with_body("/file", payload)
|
| 339 |
+
assert response.status_code == 422
|
| 340 |
+
|
| 341 |
+
def test_missing_consent(self, base_required_fields, mock_dependencies):
|
| 342 |
+
"""Test that missing consent field is rejected"""
|
| 343 |
+
payload = {**base_required_fields, "file_name": "document.txt"}
|
| 344 |
+
del payload["consent"]
|
| 345 |
+
|
| 346 |
+
response = delete_with_body("/file", payload)
|
| 347 |
+
assert response.status_code == 422
|
| 348 |
+
|
| 349 |
+
def test_invalid_roles_empty_set(self, base_required_fields, mock_dependencies):
|
| 350 |
+
"""Test that empty roles set is rejected"""
|
| 351 |
+
payload = {
|
| 352 |
+
**base_required_fields,
|
| 353 |
+
"roles": [], # Empty - violates min_length=1
|
| 354 |
+
"file_name": "document.txt",
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
response = delete_with_body("/file", payload)
|
| 358 |
+
assert response.status_code == 422
|
| 359 |
+
|
| 360 |
+
def test_invalid_roles_too_many(self, base_required_fields, mock_dependencies):
|
| 361 |
+
"""Test that more than 5 roles is rejected"""
|
| 362 |
+
payload = {
|
| 363 |
+
**base_required_fields,
|
| 364 |
+
"roles": [
|
| 365 |
+
"patient",
|
| 366 |
+
"clinician",
|
| 367 |
+
"computer-scientist",
|
| 368 |
+
"researcher",
|
| 369 |
+
"other",
|
| 370 |
+
"extra",
|
| 371 |
+
],
|
| 372 |
+
"file_name": "document.txt",
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
response = delete_with_body("/file", payload)
|
| 376 |
+
assert response.status_code == 422
|
| 377 |
+
|
| 378 |
+
def test_invalid_role_value(self, base_required_fields, mock_dependencies):
|
| 379 |
+
"""Test that invalid role values are rejected"""
|
| 380 |
+
payload = {
|
| 381 |
+
**base_required_fields,
|
| 382 |
+
"roles": ["invalid-role"],
|
| 383 |
+
"file_name": "document.txt",
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
response = delete_with_body("/file", payload)
|
| 387 |
+
assert response.status_code == 422
|
| 388 |
+
|
| 389 |
+
def test_valid_multiple_roles(self, base_required_fields, mock_dependencies):
|
| 390 |
+
"""Test that multiple valid roles are accepted"""
|
| 391 |
+
payload = {
|
| 392 |
+
**base_required_fields,
|
| 393 |
+
"roles": ["patient", "clinician", "researcher"],
|
| 394 |
+
"file_name": "document.txt",
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
response = delete_with_body("/file", payload)
|
| 398 |
+
assert response.status_code == 200
|
| 399 |
+
|
| 400 |
+
# ==================== Rate Limiting Tests ====================
|
| 401 |
+
|
| 402 |
+
@pytest.mark.enable_rate_limit
|
| 403 |
+
def test_rate_limiting(self, valid_payload, mock_dependencies):
|
| 404 |
+
"""Test that rate limiting works (20 requests per minute)"""
|
| 405 |
+
from fastapi.testclient import TestClient
|
| 406 |
+
from main import app
|
| 407 |
+
|
| 408 |
+
# Create fresh client with rate limiting enabled
|
| 409 |
+
rate_limit_client = TestClient(app)
|
| 410 |
+
|
| 411 |
+
# Make 21 rapid requests
|
| 412 |
+
responses = []
|
| 413 |
+
for i in range(21):
|
| 414 |
+
response = rate_limit_client.request("DELETE", "/file", json=valid_payload)
|
| 415 |
+
responses.append(response)
|
| 416 |
+
|
| 417 |
+
# First 20 should succeed
|
| 418 |
+
# 21st should be rate limited
|
| 419 |
+
assert responses[-1].status_code == 429
|
| 420 |
+
|
| 421 |
+
# ==================== Integration Tests ====================
|
| 422 |
+
|
| 423 |
+
def test_delete_same_file_twice_idempotent(self, valid_payload, mock_dependencies):
|
| 424 |
+
"""Test that deleting the same file twice is idempotent"""
|
| 425 |
+
# First delete
|
| 426 |
+
response1 = delete_with_body("/file", valid_payload)
|
| 427 |
+
assert response1.status_code == 200
|
| 428 |
+
|
| 429 |
+
# Second delete (file already gone)
|
| 430 |
+
mock_dependencies["store"].delete_document.return_value = False
|
| 431 |
+
response2 = delete_with_body("/file", valid_payload)
|
| 432 |
+
assert response2.status_code == 200
|
| 433 |
+
|
| 434 |
+
def test_delete_multiple_files_same_session(
|
| 435 |
+
self, base_required_fields, mock_dependencies
|
| 436 |
+
):
|
| 437 |
+
"""Test deleting multiple files from the same session"""
|
| 438 |
+
session_id = "test-session"
|
| 439 |
+
files = ["file1.txt", "file2.txt", "file3.txt"]
|
| 440 |
+
|
| 441 |
+
for filename in files:
|
| 442 |
+
payload = {
|
| 443 |
+
**base_required_fields,
|
| 444 |
+
"session_id": session_id,
|
| 445 |
+
"file_name": filename,
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
response = delete_with_body("/file", payload)
|
| 449 |
+
assert response.status_code == 200
|
| 450 |
+
|
| 451 |
+
def test_delete_files_from_multiple_sessions(
|
| 452 |
+
self, base_required_fields, mock_dependencies
|
| 453 |
+
):
|
| 454 |
+
"""Test deleting files from different sessions"""
|
| 455 |
+
sessions_and_files = [
|
| 456 |
+
("session-1", "file1.txt"),
|
| 457 |
+
("session-2", "file2.txt"),
|
| 458 |
+
("session-3", "file3.txt"),
|
| 459 |
+
]
|
| 460 |
+
|
| 461 |
+
for session_id, filename in sessions_and_files:
|
| 462 |
+
payload = {
|
| 463 |
+
**base_required_fields,
|
| 464 |
+
"session_id": session_id,
|
| 465 |
+
"file_name": filename,
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
response = delete_with_body("/file", payload)
|
| 469 |
+
assert response.status_code == 200
|
| 470 |
+
|
| 471 |
+
def test_workflow_order(self, valid_payload, mock_dependencies):
|
| 472 |
+
"""Test that operations happen in correct order"""
|
| 473 |
+
call_order = []
|
| 474 |
+
|
| 475 |
+
def track_replace(filename):
|
| 476 |
+
call_order.append("replace")
|
| 477 |
+
return filename
|
| 478 |
+
|
| 479 |
+
def track_delete(session_id, filename):
|
| 480 |
+
call_order.append("delete")
|
| 481 |
+
return True
|
| 482 |
+
|
| 483 |
+
mock_dependencies["replace_spaces"].side_effect = track_replace
|
| 484 |
+
mock_dependencies["store"].delete_document.side_effect = track_delete
|
| 485 |
+
|
| 486 |
+
delete_with_body("/file", valid_payload)
|
| 487 |
+
|
| 488 |
+
# replace_spaces should be called before delete_document
|
| 489 |
+
assert call_order == ["replace", "delete"]
|
| 490 |
+
|
| 491 |
+
def test_very_long_filename(self, base_required_fields, mock_dependencies):
|
| 492 |
+
"""Test handling of very long filenames"""
|
| 493 |
+
long_filename = "a" * MAX_FILE_NAME_LENGTH + ".txt"
|
| 494 |
+
payload = {**base_required_fields, "file_name": long_filename}
|
| 495 |
+
|
| 496 |
+
response = delete_with_body("/file", payload)
|
| 497 |
+
|
| 498 |
+
assert response.status_code == 422
|
| 499 |
+
|
| 500 |
+
def test_very_long_session_id(self, base_required_fields, mock_dependencies):
|
| 501 |
+
"""Test handling of very long session IDs"""
|
| 502 |
+
long_session_id = "s" * 51
|
| 503 |
+
payload = {
|
| 504 |
+
**base_required_fields,
|
| 505 |
+
"session_id": long_session_id,
|
| 506 |
+
"file_name": "document.txt",
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
response = delete_with_body("/file", payload)
|
| 510 |
+
|
| 511 |
+
assert response.status_code == 422
|