qyle commited on
Commit
42e191f
·
verified ·
1 Parent(s): 18b7653
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .coverage +0 -0
  2. .gitignore +2 -1
  3. README.md +36 -0
  4. champ/rag.py +2 -1
  5. champ/service.py +13 -5
  6. classes/base_models.py +20 -2
  7. classes/ocr_reader.py +27 -0
  8. classes/pii_filter.py +50 -4
  9. classes/session_conversation_store.py +2 -5
  10. classes/session_document_store.py +11 -18
  11. classes/session_tracker.py +0 -1
  12. conftest.py +10 -0
  13. constants.py +6 -3
  14. exceptions.py +61 -0
  15. helpers/file_helper.py +176 -35
  16. helpers/lifespan_helper.py +52 -0
  17. helpers/llm_helper.py +129 -0
  18. helpers/message_helper.py +54 -0
  19. main.py +91 -367
  20. pytest.ini +14 -0
  21. requirements-dev.txt +7 -0
  22. static/app.js +36 -749
  23. static/components/chat-component.js +276 -0
  24. static/components/comment-component.js +120 -0
  25. static/components/consent-component.js +50 -0
  26. static/components/feedback-component.js +200 -0
  27. static/components/file-upload-component.js +273 -0
  28. static/components/language-component.js +58 -0
  29. static/components/profile-component.js +108 -0
  30. static/components/settings-component.js +118 -0
  31. static/services/api-service.js +201 -0
  32. static/services/state-manager.js +145 -0
  33. static/services/translation-service.js +48 -0
  34. static/styles/base.css +359 -0
  35. static/styles/components/chat.css +133 -0
  36. static/styles/components/comment.css +50 -0
  37. static/styles/components/consent.css +6 -0
  38. static/styles/components/feedback.css +198 -0
  39. static/styles/components/file-upload.css +68 -0
  40. static/styles/components/settings.css +58 -0
  41. static/styles/control-bar.css +38 -0
  42. static/styles/snackbar.css +51 -0
  43. static/translations.js +81 -18
  44. static/utils.js +56 -0
  45. templates/index.html +98 -32
  46. tests/api/conftest.py +16 -0
  47. tests/api/test_chat_post.py +467 -0
  48. tests/api/test_comment_post.py +237 -0
  49. tests/api/test_feedback_post.py +99 -0
  50. 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
- documents: List[Document],
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 override_reply, {
48
- "triage_triggered": True,
49
- "triage_reason": reason,
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 List, Literal, Set
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.8
 
 
 
 
 
 
 
 
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(supported_entity="ZIP_CODE", patterns=[zip_code_pattern])
 
 
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
- print("Initializing Presidio Engines (this should happen only once)...")
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
- from typing import Dict, List, Tuple
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
- self, session_id: str, file_text: str, file_name: str, file_size: int
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 + file_size > MAX_FILE_SIZES_PER_SESSION:
25
  return False
26
 
27
- self.session_document_map[session_id][file_name] = (file_text, file_size)
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
- if session_id in self.session_document_map:
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 = 1000
24
- MAX_COMMENT_LENGTH = 500
 
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
- "application/zip", # docx files are actually zip files under the hood and are detected as such by magic
 
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 easyocr
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 constants import FILE_CHUNK_SIZE, MAX_FILE_SIZE
 
 
 
 
 
 
 
 
 
 
14
 
15
 
16
  def clean_text(raw_text: str):
17
- # TODO: Try to keep paragraphs (\n\n)
18
- # 1. Strip whitespace from the beginning and end of every single line
19
- # This handles the "spaces followed by newlines" issue
20
  lines = [line.strip() for line in raw_text.splitlines()]
21
 
22
- # 2. Remove completely empty lines from the list
23
- non_empty_lines = [line for line in lines if line]
 
24
 
25
- # 3. Join them back together with a single newline
26
- text = "\n".join(non_empty_lines)
 
 
 
 
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
- # TODO: OCR if reading binary files doesn't work
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
- return False # bail out immediately
 
 
71
  return True
72
  except zipfile.BadZipFile:
73
- return False
74
 
75
 
76
- async def extract_text_from_docx(binary_content: bytes):
 
 
 
77
  # Load the binary data into a stream
78
  stream = io.BytesIO(binary_content)
79
 
80
  # Load the docx document
81
- doc = Document(stream)
 
 
 
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
- img = Image.open(io.BytesIO(binary_content)).convert("RGB")
95
- arr = np.array(img, dtype=np.int16)
96
- noise = np.random.randint(-1, 2, arr.shape) # -1, 0, or 1
97
- arr = np.clip(arr + noise, 0, 255).astype(np.uint8)
98
- output = io.BytesIO()
99
- Image.fromarray(arr).save(output, format="PNG")
100
- return output.getvalue()
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
- res = ocr_reader.readtext(img, detail=0)
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 easyocr
4
- import magic
5
- import psutil
6
- import torch
7
-
8
  from contextlib import asynccontextmanager
 
9
 
10
- from typing import AsyncGenerator, List, Literal, Tuple, Dict, Any
11
-
12
  from dotenv import load_dotenv
13
-
14
- from fastapi import FastAPI, File, Form, Request, BackgroundTasks, Response, UploadFile
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 helpers.dynamodb_helper import log_event
59
-
60
- from openai import AsyncOpenAI
61
- from google import genai
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 champ.service import ChampService
73
-
74
  from helpers.file_helper import (
75
- extract_text_from_docx,
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
- safe_unzip_check,
82
- sanitize_image,
83
  )
84
- from classes.session_document_store import SessionDocumentStore
 
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
- print(f"Is CUDA available: {torch.cuda.is_available()}")
285
 
286
- print("Loading the OCR model into memory...")
287
- # We are loading the OCR Reader in advance, because loading the model takes time.
288
- app.state.ocr_reader = easyocr.Reader(["en", "fr"], gpu=torch.cuda.is_available())
 
 
289
 
290
- # languages = [Language.ENGLISH, Language.FRENCH]
291
- # app.state.language_detector = LanguageDetectorBuilder.from_languages(
292
- # *languages
293
- # ).build()
294
 
295
- # Idem for the prompt sanitizer. No need to store it in the state since this
296
- # class follows the Singleton design pattern.
297
- PIIFilter()
 
 
 
298
 
299
- bg_task = asyncio.create_task(cleanup_loop())
 
 
 
 
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
- # pii_filtered_msg = pii_filter.sanitize(
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
- conversation = session_conversation_store.get_conversation(
364
- session_id, conversation_id
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, session_id, model_type, lang, conversation
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
- if not payload.comment:
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
- # Preliminary checks
497
- file_size = file.size
498
- if file_size is None:
499
- return Response(status_code=STATUS_CODE_LENGTH_REQUIRED)
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
- file_name = replace_spaces_in_filename(file_name)
 
 
513
 
514
- if not is_valid_filename(file_name):
515
- return Response(status_code=STATUS_CODE_UNPROCESSABLE_CONTENT)
516
-
517
- _, extension = os.path.splitext(file_name)
518
- if extension not in SUPPORTED_FILE_EXTENSIONS:
519
- print("Unsupported extension")
520
- return Response(status_code=STATUS_CODE_UNSUPPORTED_MEDIA_TYPE)
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
- # pii_filtered_file_text = pii_filter.sanitize(
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, file_size
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
- const browserLang = navigator.language.split('-')[0];
2
- const defaultLang = ['en', 'fr'].includes(browserLang) ? browserLang : 'en';
3
- let currentLang = localStorage.getItem('preferredLang') || defaultLang;
4
-
5
- const chatWindow = document.getElementById('chatWindow');
6
- const userInput = document.getElementById('userInput');
7
- const sendBtn = document.getElementById('sendBtn');
8
-
9
- const uploadFileBtn = document.getElementById('upload-file-btn');
10
- const uploadFileOverlay = document.getElementById('upload-file-overlay');
11
- const fileDropZone = document.getElementById('file-drop-zone');
12
- const fileInput = document.getElementById('file-input');
13
- const doneFileUploadBtn = document.getElementById('done-file-upload');
14
- const closeFileUploadBtn = document.getElementById('close-file-upload-btn');
15
- const fileListHtml = document.getElementById('file-list');
16
-
17
- const langSwitchContainer = document.getElementById('lang-switch-container');
18
- const enBtn = document.getElementById('btn-en');
19
- const frBtn = document.getElementById('btn-fr');
20
-
21
- document.createElement('svg');
22
-
23
- const HTML_UPLOAD_ICON = `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
24
- <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" />
25
- </svg>`;
26
-
27
- const HTML_SPINNER_ICON = `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" class="spinning">
28
- <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" />
29
- </svg>`;
30
-
31
- const HTML_CHECK_ICON = `
32
- <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
33
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
34
- </svg>`;
35
-
36
- const HTML_TRASH_ICON = `<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
- 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 chatbots powered by different models. 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,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 using the options in the toolbar, or in the top right corner when a dialog is open.",
 
 
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. This data allows us to improve the system and ensure the tool is effective for everyone.",
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 10MB).",
51
- file_size_limit: "The total size of all uploaded files cannot exceed 30MB.",
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
- file_upload_failed_server_error: "File upload was unsuccessful due to a server error.",
68
- file_upload_failed_network_error: "File upload was unsuccessful due to a network error.",
 
 
 
 
 
 
 
69
  file_upload_success: "File upload successful!",
70
 
71
- file_delete_failed_server_error: "File deletion was unsuccessful due to a server error.",
72
- file_delete_failed_network_error: "File deletion was unsuccessful due to a network error.",
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 des chatbots propulsés par différents modèles et comparez-les. Veillez à ne partager aucune information sensible ou privée durant la conversation.",
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. Commencer une nouvelle conversation !",
103
 
104
  choose_language_title: "Choisissez votre langue",
105
- change_language_instructions: "Vous pouvez changer la langue à tout moment grâce aux options dans la barre d'outils, ou en haut à droite lorsqu'une fenêtre est ouverte.",
 
 
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. Ces données nous permettent d'améliorer le système et d'assurer que cet outil est efficace pour tout le monde.",
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
- file_upload_failed_server_error: "Le téléversement du fichier a échoué en raison d'une erreur du serveur.",
156
- file_upload_failed_network_error: "Le téléversement du fichier a échoué en raison d'une erreur réseau.",
 
 
 
 
 
 
 
157
  file_upload_success: "Téléversement du fichier réussi !",
158
 
159
- file_delete_failed_server_error: "La suppression du fichier a échoué en raison d'une erreur du serveur.",
160
- file_delete_failed_network_error: "La suppression du fichier a échoué en raison d'une erreur réseau.",
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
- <link rel="stylesheet" href="/static/snackbar.css" />
11
- <link rel="stylesheet" href="/static/style.css" />
12
- <!-- Include marked.js for Markdown rendering -->
13
- <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
14
- <!-- Include DOMPurify to sanitize HTML -->
15
- <script src="https://cdn.jsdelivr.net/npm/dompurify@2.4.2/dist/purify.min.js"></script>
 
 
 
 
 
 
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
- <div class="control-group">
34
- <label for="systemPreset" data-i18n="model_selection"></label>
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
- </div>
 
 
 
 
43
 
44
- <button id="clearBtn" class="secondary-button" data-i18n="btn_clear"></button>
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
- <div class="lang-switch-container" id="lang-switch-container">
47
- <button id="btn-en" class="lang-btn">EN</button>
48
- <span class="separator">|</span>
49
- <button id="btn-fr" class="lang-btn">FR</button>
 
 
 
 
 
 
 
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="1000"
 
155
  ></textarea>
156
  <div class="chat-toolbar">
157
- <button id="upload-file-btn" title="Upload file" class="toolbar-btn" data-i18n="btn_add_file"></button>
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" aria-label="Close">×</button>
174
  <h2 data-i18n="comment_title"></h2>
175
  <textarea
176
  id="commentInput"
177
- maxlength="1000"
 
 
178
  ></textarea>
179
  <div id="commentStatus" class="comment-status"></div>
180
- <button id="cancelCommentBtn" class="cancelBtn" data-i18n="btn_cancel"></button>
181
- <button id="sendCommentBtn" class="ok-button" data-i18n="btn_send"></button>
 
 
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" aria-label="Close">×</button>
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
- <div class="font-size-container">
220
- <button id="increase-font-size-btn" class="font-size-btn">Aa+</button>
221
- <button id="reset-font-size-btn" class="font-size-btn">Aa</button>
222
- <button id="decrease-font-size-btn" class="font-size-btn">Aa-</button>
223
- </div>
224
-
225
  <script src="/static/translations.js"></script>
226
  <script src="/static/snackbar.js"></script>
227
- <script src="/static/app.js"></script>
 
 
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