atlas-chat 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. atlas/__init__.py +40 -0
  2. atlas/application/__init__.py +7 -0
  3. atlas/application/chat/__init__.py +7 -0
  4. atlas/application/chat/agent/__init__.py +10 -0
  5. atlas/application/chat/agent/act_loop.py +179 -0
  6. atlas/application/chat/agent/factory.py +142 -0
  7. atlas/application/chat/agent/protocols.py +46 -0
  8. atlas/application/chat/agent/react_loop.py +338 -0
  9. atlas/application/chat/agent/think_act_loop.py +171 -0
  10. atlas/application/chat/approval_manager.py +151 -0
  11. atlas/application/chat/elicitation_manager.py +191 -0
  12. atlas/application/chat/events/__init__.py +1 -0
  13. atlas/application/chat/events/agent_event_relay.py +112 -0
  14. atlas/application/chat/modes/__init__.py +1 -0
  15. atlas/application/chat/modes/agent.py +125 -0
  16. atlas/application/chat/modes/plain.py +74 -0
  17. atlas/application/chat/modes/rag.py +81 -0
  18. atlas/application/chat/modes/tools.py +179 -0
  19. atlas/application/chat/orchestrator.py +213 -0
  20. atlas/application/chat/policies/__init__.py +1 -0
  21. atlas/application/chat/policies/tool_authorization.py +99 -0
  22. atlas/application/chat/preprocessors/__init__.py +1 -0
  23. atlas/application/chat/preprocessors/message_builder.py +92 -0
  24. atlas/application/chat/preprocessors/prompt_override_service.py +104 -0
  25. atlas/application/chat/service.py +454 -0
  26. atlas/application/chat/utilities/__init__.py +6 -0
  27. atlas/application/chat/utilities/error_handler.py +367 -0
  28. atlas/application/chat/utilities/event_notifier.py +546 -0
  29. atlas/application/chat/utilities/file_processor.py +613 -0
  30. atlas/application/chat/utilities/tool_executor.py +789 -0
  31. atlas/atlas_chat_cli.py +347 -0
  32. atlas/atlas_client.py +238 -0
  33. atlas/core/__init__.py +0 -0
  34. atlas/core/auth.py +205 -0
  35. atlas/core/authorization_manager.py +27 -0
  36. atlas/core/capabilities.py +123 -0
  37. atlas/core/compliance.py +215 -0
  38. atlas/core/domain_whitelist.py +147 -0
  39. atlas/core/domain_whitelist_middleware.py +82 -0
  40. atlas/core/http_client.py +28 -0
  41. atlas/core/log_sanitizer.py +102 -0
  42. atlas/core/metrics_logger.py +59 -0
  43. atlas/core/middleware.py +131 -0
  44. atlas/core/otel_config.py +242 -0
  45. atlas/core/prompt_risk.py +200 -0
  46. atlas/core/rate_limit.py +0 -0
  47. atlas/core/rate_limit_middleware.py +64 -0
  48. atlas/core/security_headers_middleware.py +51 -0
  49. atlas/domain/__init__.py +37 -0
  50. atlas/domain/chat/__init__.py +1 -0
  51. atlas/domain/chat/dtos.py +85 -0
  52. atlas/domain/errors.py +96 -0
  53. atlas/domain/messages/__init__.py +12 -0
  54. atlas/domain/messages/models.py +160 -0
  55. atlas/domain/rag_mcp_service.py +664 -0
  56. atlas/domain/sessions/__init__.py +7 -0
  57. atlas/domain/sessions/models.py +36 -0
  58. atlas/domain/unified_rag_service.py +371 -0
  59. atlas/infrastructure/__init__.py +10 -0
  60. atlas/infrastructure/app_factory.py +135 -0
  61. atlas/infrastructure/events/__init__.py +1 -0
  62. atlas/infrastructure/events/cli_event_publisher.py +140 -0
  63. atlas/infrastructure/events/websocket_publisher.py +140 -0
  64. atlas/infrastructure/sessions/in_memory_repository.py +56 -0
  65. atlas/infrastructure/transport/__init__.py +7 -0
  66. atlas/infrastructure/transport/websocket_connection_adapter.py +33 -0
  67. atlas/init_cli.py +226 -0
  68. atlas/interfaces/__init__.py +15 -0
  69. atlas/interfaces/events.py +134 -0
  70. atlas/interfaces/llm.py +54 -0
  71. atlas/interfaces/rag.py +40 -0
  72. atlas/interfaces/sessions.py +75 -0
  73. atlas/interfaces/tools.py +57 -0
  74. atlas/interfaces/transport.py +24 -0
  75. atlas/main.py +564 -0
  76. atlas/mcp/api_key_demo/README.md +76 -0
  77. atlas/mcp/api_key_demo/main.py +172 -0
  78. atlas/mcp/api_key_demo/run.sh +56 -0
  79. atlas/mcp/basictable/main.py +147 -0
  80. atlas/mcp/calculator/main.py +149 -0
  81. atlas/mcp/code-executor/execution_engine.py +98 -0
  82. atlas/mcp/code-executor/execution_environment.py +95 -0
  83. atlas/mcp/code-executor/main.py +528 -0
  84. atlas/mcp/code-executor/result_processing.py +276 -0
  85. atlas/mcp/code-executor/script_generation.py +195 -0
  86. atlas/mcp/code-executor/security_checker.py +140 -0
  87. atlas/mcp/corporate_cars/main.py +437 -0
  88. atlas/mcp/csv_reporter/main.py +545 -0
  89. atlas/mcp/duckduckgo/main.py +182 -0
  90. atlas/mcp/elicitation_demo/README.md +171 -0
  91. atlas/mcp/elicitation_demo/main.py +262 -0
  92. atlas/mcp/env-demo/README.md +158 -0
  93. atlas/mcp/env-demo/main.py +199 -0
  94. atlas/mcp/file_size_test/main.py +284 -0
  95. atlas/mcp/filesystem/main.py +348 -0
  96. atlas/mcp/image_demo/main.py +113 -0
  97. atlas/mcp/image_demo/requirements.txt +4 -0
  98. atlas/mcp/logging_demo/README.md +72 -0
  99. atlas/mcp/logging_demo/main.py +103 -0
  100. atlas/mcp/many_tools_demo/main.py +50 -0
  101. atlas/mcp/order_database/__init__.py +0 -0
  102. atlas/mcp/order_database/main.py +369 -0
  103. atlas/mcp/order_database/signal_data.csv +1001 -0
  104. atlas/mcp/pdfbasic/main.py +394 -0
  105. atlas/mcp/pptx_generator/main.py +760 -0
  106. atlas/mcp/pptx_generator/requirements.txt +13 -0
  107. atlas/mcp/pptx_generator/run_test.sh +1 -0
  108. atlas/mcp/pptx_generator/test_pptx_generator_security.py +169 -0
  109. atlas/mcp/progress_demo/main.py +167 -0
  110. atlas/mcp/progress_updates_demo/QUICKSTART.md +273 -0
  111. atlas/mcp/progress_updates_demo/README.md +120 -0
  112. atlas/mcp/progress_updates_demo/main.py +497 -0
  113. atlas/mcp/prompts/main.py +222 -0
  114. atlas/mcp/public_demo/main.py +189 -0
  115. atlas/mcp/sampling_demo/README.md +169 -0
  116. atlas/mcp/sampling_demo/main.py +234 -0
  117. atlas/mcp/thinking/main.py +77 -0
  118. atlas/mcp/tool_planner/main.py +240 -0
  119. atlas/mcp/ui-demo/badmesh.png +0 -0
  120. atlas/mcp/ui-demo/main.py +383 -0
  121. atlas/mcp/ui-demo/templates/button_demo.html +32 -0
  122. atlas/mcp/ui-demo/templates/data_visualization.html +32 -0
  123. atlas/mcp/ui-demo/templates/form_demo.html +28 -0
  124. atlas/mcp/username-override-demo/README.md +320 -0
  125. atlas/mcp/username-override-demo/main.py +308 -0
  126. atlas/modules/__init__.py +0 -0
  127. atlas/modules/config/__init__.py +34 -0
  128. atlas/modules/config/cli.py +231 -0
  129. atlas/modules/config/config_manager.py +1096 -0
  130. atlas/modules/file_storage/__init__.py +22 -0
  131. atlas/modules/file_storage/cli.py +330 -0
  132. atlas/modules/file_storage/content_extractor.py +290 -0
  133. atlas/modules/file_storage/manager.py +295 -0
  134. atlas/modules/file_storage/mock_s3_client.py +402 -0
  135. atlas/modules/file_storage/s3_client.py +417 -0
  136. atlas/modules/llm/__init__.py +19 -0
  137. atlas/modules/llm/caller.py +287 -0
  138. atlas/modules/llm/litellm_caller.py +675 -0
  139. atlas/modules/llm/models.py +19 -0
  140. atlas/modules/mcp_tools/__init__.py +17 -0
  141. atlas/modules/mcp_tools/client.py +2123 -0
  142. atlas/modules/mcp_tools/token_storage.py +556 -0
  143. atlas/modules/prompts/prompt_provider.py +130 -0
  144. atlas/modules/rag/__init__.py +24 -0
  145. atlas/modules/rag/atlas_rag_client.py +336 -0
  146. atlas/modules/rag/client.py +129 -0
  147. atlas/routes/admin_routes.py +865 -0
  148. atlas/routes/config_routes.py +484 -0
  149. atlas/routes/feedback_routes.py +361 -0
  150. atlas/routes/files_routes.py +274 -0
  151. atlas/routes/health_routes.py +40 -0
  152. atlas/routes/mcp_auth_routes.py +223 -0
  153. atlas/server_cli.py +164 -0
  154. atlas/tests/conftest.py +20 -0
  155. atlas/tests/integration/test_mcp_auth_integration.py +152 -0
  156. atlas/tests/manual_test_sampling.py +87 -0
  157. atlas/tests/modules/mcp_tools/test_client_auth.py +226 -0
  158. atlas/tests/modules/mcp_tools/test_client_env.py +191 -0
  159. atlas/tests/test_admin_mcp_server_management_routes.py +141 -0
  160. atlas/tests/test_agent_roa.py +135 -0
  161. atlas/tests/test_app_factory_smoke.py +47 -0
  162. atlas/tests/test_approval_manager.py +439 -0
  163. atlas/tests/test_atlas_client.py +188 -0
  164. atlas/tests/test_atlas_rag_client.py +447 -0
  165. atlas/tests/test_atlas_rag_integration.py +224 -0
  166. atlas/tests/test_attach_file_flow.py +287 -0
  167. atlas/tests/test_auth_utils.py +165 -0
  168. atlas/tests/test_backend_public_url.py +185 -0
  169. atlas/tests/test_banner_logging.py +287 -0
  170. atlas/tests/test_capability_tokens_and_injection.py +203 -0
  171. atlas/tests/test_compliance_level.py +54 -0
  172. atlas/tests/test_compliance_manager.py +253 -0
  173. atlas/tests/test_config_manager.py +617 -0
  174. atlas/tests/test_config_manager_paths.py +12 -0
  175. atlas/tests/test_core_auth.py +18 -0
  176. atlas/tests/test_core_utils.py +190 -0
  177. atlas/tests/test_docker_env_sync.py +202 -0
  178. atlas/tests/test_domain_errors.py +329 -0
  179. atlas/tests/test_domain_whitelist.py +359 -0
  180. atlas/tests/test_elicitation_manager.py +408 -0
  181. atlas/tests/test_elicitation_routing.py +296 -0
  182. atlas/tests/test_env_demo_server.py +88 -0
  183. atlas/tests/test_error_classification.py +113 -0
  184. atlas/tests/test_error_flow_integration.py +116 -0
  185. atlas/tests/test_feedback_routes.py +333 -0
  186. atlas/tests/test_file_content_extraction.py +1134 -0
  187. atlas/tests/test_file_extraction_routes.py +158 -0
  188. atlas/tests/test_file_library.py +107 -0
  189. atlas/tests/test_file_manager_unit.py +18 -0
  190. atlas/tests/test_health_route.py +49 -0
  191. atlas/tests/test_http_client_stub.py +8 -0
  192. atlas/tests/test_imports_smoke.py +30 -0
  193. atlas/tests/test_interfaces_llm_response.py +9 -0
  194. atlas/tests/test_issue_access_denied_fix.py +136 -0
  195. atlas/tests/test_llm_env_expansion.py +836 -0
  196. atlas/tests/test_log_level_sensitive_data.py +285 -0
  197. atlas/tests/test_mcp_auth_routes.py +341 -0
  198. atlas/tests/test_mcp_client_auth.py +331 -0
  199. atlas/tests/test_mcp_data_injection.py +270 -0
  200. atlas/tests/test_mcp_get_authorized_servers.py +95 -0
  201. atlas/tests/test_mcp_hot_reload.py +512 -0
  202. atlas/tests/test_mcp_image_content.py +424 -0
  203. atlas/tests/test_mcp_logging.py +172 -0
  204. atlas/tests/test_mcp_progress_updates.py +313 -0
  205. atlas/tests/test_mcp_prompt_override_system_prompt.py +102 -0
  206. atlas/tests/test_mcp_prompts_server.py +39 -0
  207. atlas/tests/test_mcp_tool_result_parsing.py +296 -0
  208. atlas/tests/test_metrics_logger.py +56 -0
  209. atlas/tests/test_middleware_auth.py +379 -0
  210. atlas/tests/test_prompt_risk_and_acl.py +141 -0
  211. atlas/tests/test_rag_mcp_aggregator.py +204 -0
  212. atlas/tests/test_rag_mcp_service.py +224 -0
  213. atlas/tests/test_rate_limit_middleware.py +45 -0
  214. atlas/tests/test_routes_config_smoke.py +60 -0
  215. atlas/tests/test_routes_files_download_token.py +41 -0
  216. atlas/tests/test_routes_files_health.py +18 -0
  217. atlas/tests/test_runtime_imports.py +53 -0
  218. atlas/tests/test_sampling_integration.py +482 -0
  219. atlas/tests/test_security_admin_routes.py +61 -0
  220. atlas/tests/test_security_capability_tokens.py +65 -0
  221. atlas/tests/test_security_file_stats_scope.py +21 -0
  222. atlas/tests/test_security_header_injection.py +191 -0
  223. atlas/tests/test_security_headers_and_filename.py +63 -0
  224. atlas/tests/test_shared_session_repository.py +101 -0
  225. atlas/tests/test_system_prompt_loading.py +181 -0
  226. atlas/tests/test_token_storage.py +505 -0
  227. atlas/tests/test_tool_approval_config.py +93 -0
  228. atlas/tests/test_tool_approval_utils.py +356 -0
  229. atlas/tests/test_tool_authorization_group_filtering.py +223 -0
  230. atlas/tests/test_tool_details_in_config.py +108 -0
  231. atlas/tests/test_tool_planner.py +300 -0
  232. atlas/tests/test_unified_rag_service.py +398 -0
  233. atlas/tests/test_username_override_in_approval.py +258 -0
  234. atlas/tests/test_websocket_auth_header.py +168 -0
  235. atlas/version.py +6 -0
  236. atlas_chat-0.1.0.data/data/.env.example +253 -0
  237. atlas_chat-0.1.0.data/data/config/defaults/compliance-levels.json +44 -0
  238. atlas_chat-0.1.0.data/data/config/defaults/domain-whitelist.json +123 -0
  239. atlas_chat-0.1.0.data/data/config/defaults/file-extractors.json +74 -0
  240. atlas_chat-0.1.0.data/data/config/defaults/help-config.json +198 -0
  241. atlas_chat-0.1.0.data/data/config/defaults/llmconfig-buggy.yml +11 -0
  242. atlas_chat-0.1.0.data/data/config/defaults/llmconfig.yml +19 -0
  243. atlas_chat-0.1.0.data/data/config/defaults/mcp.json +138 -0
  244. atlas_chat-0.1.0.data/data/config/defaults/rag-sources.json +17 -0
  245. atlas_chat-0.1.0.data/data/config/defaults/splash-config.json +16 -0
  246. atlas_chat-0.1.0.dist-info/METADATA +236 -0
  247. atlas_chat-0.1.0.dist-info/RECORD +250 -0
  248. atlas_chat-0.1.0.dist-info/WHEEL +5 -0
  249. atlas_chat-0.1.0.dist-info/entry_points.txt +4 -0
  250. atlas_chat-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,361 @@
1
+ """Feedback routes for user feedback collection and management.
2
+
3
+ This module provides endpoints for:
4
+ - Submitting user feedback with ratings and comments
5
+ - Admin viewing of collected feedback data
6
+ - Downloading feedback data as CSV or JSON
7
+ """
8
+
9
+ import csv
10
+ import io
11
+ import json
12
+ import logging
13
+ import uuid
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+ from typing import Any, Dict, Literal
17
+
18
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request
19
+ from fastapi.responses import StreamingResponse
20
+ from pydantic import BaseModel
21
+
22
+ from atlas.core.auth import is_user_in_group
23
+ from atlas.core.log_sanitizer import get_current_user, sanitize_for_logging
24
+ from atlas.infrastructure.app_factory import app_factory
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ # Feedback router
29
+ feedback_router = APIRouter(prefix="/api", tags=["feedback"])
30
+
31
+
32
+ class FeedbackData(BaseModel):
33
+ """Model for user feedback submission."""
34
+ rating: int # -1, 0, or 1
35
+ comment: str = ""
36
+ session: dict = {}
37
+
38
+
39
+ class FeedbackResponse(BaseModel):
40
+ """Model for feedback list responses."""
41
+ id: str
42
+ timestamp: str
43
+ user: str
44
+ rating: int
45
+ comment: str
46
+ session_info: dict
47
+ server_context: dict
48
+
49
+
50
+ def get_feedback_directory() -> Path:
51
+ """Get the feedback storage directory."""
52
+ from atlas.modules.config import config_manager
53
+ base = Path(config_manager.app_settings.runtime_feedback_dir)
54
+ base.mkdir(parents=True, exist_ok=True)
55
+ return base
56
+
57
+
58
+ async def require_admin_for_feedback(current_user: str = Depends(get_current_user)) -> str:
59
+ """Dependency to require admin group membership for feedback viewing."""
60
+ config_manager = app_factory.get_config_manager()
61
+ admin_group = config_manager.app_settings.admin_group
62
+ if not await is_user_in_group(current_user, admin_group):
63
+ raise HTTPException(
64
+ status_code=403,
65
+ detail=f"Admin access required to view feedback. User must be in '{admin_group}' group."
66
+ )
67
+ return current_user
68
+
69
+
70
+ @feedback_router.post("/feedback")
71
+ async def submit_feedback(
72
+ feedback: FeedbackData,
73
+ request: Request,
74
+ current_user: str = Depends(get_current_user)
75
+ ):
76
+ """Submit user feedback and save it as a JSON file."""
77
+ try:
78
+ # Validate rating
79
+ if feedback.rating not in [-1, 0, 1]:
80
+ raise HTTPException(status_code=400, detail="Rating must be -1, 0, or 1")
81
+
82
+ # Get feedback directory
83
+ feedback_dir = get_feedback_directory()
84
+
85
+ # Generate unique filename with timestamp
86
+ timestamp = datetime.now().isoformat().replace(":", "-").replace(".", "-")
87
+ feedback_id = str(uuid.uuid4())[:8]
88
+ filename = f"feedback_{timestamp}_{feedback_id}.json"
89
+
90
+ # Prepare feedback data with additional context
91
+ feedback_data = {
92
+ "id": feedback_id,
93
+ "timestamp": datetime.now().isoformat(),
94
+ "user": current_user,
95
+ "rating": feedback.rating,
96
+ "comment": feedback.comment.strip(),
97
+ "session_info": feedback.session,
98
+ "server_context": {
99
+ "user_agent": request.headers.get("user-agent", ""),
100
+ "client_host": request.client.host if request.client else "unknown",
101
+ "forwarded_for": request.headers.get("x-forwarded-for", ""),
102
+ "referer": request.headers.get("referer", "")
103
+ }
104
+ }
105
+
106
+ # Save feedback to JSON file
107
+ feedback_file = feedback_dir / filename
108
+ with open(feedback_file, 'w', encoding='utf-8') as f:
109
+ json.dump(feedback_data, f, indent=2, ensure_ascii=False)
110
+
111
+ rating_label = {1: "positive", 0: "neutral", -1: "negative"}.get(feedback.rating, "unknown")
112
+ safe_current_user = sanitize_for_logging(current_user)
113
+ logger.info(
114
+ "Feedback submitted",
115
+ extra={
116
+ "user": safe_current_user,
117
+ "rating": rating_label,
118
+ "id": feedback_id,
119
+ "file": str(feedback_file)
120
+ }
121
+ )
122
+
123
+ return {
124
+ "message": "Feedback submitted successfully",
125
+ "feedback_id": feedback_id,
126
+ "timestamp": feedback_data["timestamp"]
127
+ }
128
+
129
+ except HTTPException:
130
+ raise
131
+ except Exception as e:
132
+ logger.error(f"Error saving feedback: {e}", exc_info=True)
133
+ raise HTTPException(status_code=500, detail="Failed to save feedback")
134
+
135
+
136
+ @feedback_router.get("/feedback")
137
+ async def get_all_feedback(
138
+ limit: int = 50,
139
+ offset: int = 0,
140
+ admin_user: str = Depends(require_admin_for_feedback)
141
+ ) -> Dict[str, Any]:
142
+ """Get all submitted feedback (admin only)."""
143
+ try:
144
+ feedback_dir = get_feedback_directory()
145
+
146
+ # Get all feedback files, sorted by creation time (newest first)
147
+ feedback_files = sorted(
148
+ feedback_dir.glob("feedback_*.json"),
149
+ key=lambda x: x.stat().st_mtime,
150
+ reverse=True
151
+ )
152
+
153
+ # Apply pagination
154
+ total_count = len(feedback_files)
155
+ paginated_files = feedback_files[offset:offset + limit]
156
+
157
+ # Read and parse feedback files
158
+ feedback_list = []
159
+ for feedback_file in paginated_files:
160
+ try:
161
+ with open(feedback_file, 'r', encoding='utf-8') as f:
162
+ feedback_data = json.load(f)
163
+ feedback_list.append(feedback_data)
164
+ except Exception as e:
165
+ logger.error(f"Error reading feedback file {feedback_file}: {e}")
166
+ continue
167
+
168
+ # Calculate rating statistics
169
+ ratings = [fb["rating"] for fb in feedback_list if "rating" in fb]
170
+ rating_stats = {
171
+ "positive": sum(1 for r in ratings if r == 1),
172
+ "neutral": sum(1 for r in ratings if r == 0),
173
+ "negative": sum(1 for r in ratings if r == -1),
174
+ "total": len(ratings),
175
+ "average": sum(ratings) / len(ratings) if ratings else 0
176
+ }
177
+
178
+ return {
179
+ "feedback": feedback_list,
180
+ "pagination": {
181
+ "total": total_count,
182
+ "limit": limit,
183
+ "offset": offset,
184
+ "has_more": offset + limit < total_count
185
+ },
186
+ "statistics": rating_stats,
187
+ "retrieved_by": admin_user
188
+ }
189
+
190
+ except Exception as e:
191
+ logger.error(f"Error retrieving feedback: {e}", exc_info=True)
192
+ raise HTTPException(status_code=500, detail="Failed to retrieve feedback")
193
+
194
+
195
+ @feedback_router.get("/feedback/stats")
196
+ async def get_feedback_stats(
197
+ admin_user: str = Depends(require_admin_for_feedback)
198
+ ) -> Dict[str, Any]:
199
+ """Get feedback statistics summary (admin only)."""
200
+ try:
201
+ feedback_dir = get_feedback_directory()
202
+ feedback_files = list(feedback_dir.glob("feedback_*.json"))
203
+
204
+ if not feedback_files:
205
+ return {
206
+ "total_feedback": 0,
207
+ "rating_distribution": {"positive": 0, "neutral": 0, "negative": 0},
208
+ "average_rating": 0,
209
+ "recent_feedback": 0
210
+ }
211
+
212
+ # Read all feedback files
213
+ all_feedback = []
214
+ recent_count = 0
215
+ now = datetime.now()
216
+
217
+ for feedback_file in feedback_files:
218
+ try:
219
+ with open(feedback_file, 'r', encoding='utf-8') as f:
220
+ feedback_data = json.load(f)
221
+ all_feedback.append(feedback_data)
222
+
223
+ # Count recent feedback (last 24 hours)
224
+ if "timestamp" in feedback_data:
225
+ feedback_time = datetime.fromisoformat(feedback_data["timestamp"].replace("Z", "+00:00"))
226
+ if (now - feedback_time).total_seconds() < 86400: # 24 hours
227
+ recent_count += 1
228
+
229
+ except Exception as e:
230
+ logger.error(f"Error reading feedback file {feedback_file}: {e}")
231
+ continue
232
+
233
+ # Calculate statistics
234
+ ratings = [fb["rating"] for fb in all_feedback if "rating" in fb]
235
+ rating_distribution = {
236
+ "positive": sum(1 for r in ratings if r == 1),
237
+ "neutral": sum(1 for r in ratings if r == 0),
238
+ "negative": sum(1 for r in ratings if r == -1)
239
+ }
240
+
241
+ return {
242
+ "total_feedback": len(all_feedback),
243
+ "rating_distribution": rating_distribution,
244
+ "average_rating": sum(ratings) / len(ratings) if ratings else 0,
245
+ "recent_feedback": recent_count,
246
+ "feedback_with_comments": sum(1 for fb in all_feedback if fb.get("comment", "").strip()),
247
+ "unique_users": len(set(fb.get("user", "unknown") for fb in all_feedback)),
248
+ "retrieved_by": admin_user
249
+ }
250
+
251
+ except Exception as e:
252
+ logger.error(f"Error calculating feedback stats: {e}", exc_info=True)
253
+ raise HTTPException(status_code=500, detail="Failed to calculate feedback statistics")
254
+
255
+
256
+ @feedback_router.delete("/feedback/{feedback_id}")
257
+ async def delete_feedback(
258
+ feedback_id: str,
259
+ admin_user: str = Depends(require_admin_for_feedback)
260
+ ):
261
+ """Delete a specific feedback entry (admin only)."""
262
+ try:
263
+ feedback_dir = get_feedback_directory()
264
+
265
+ # Find the feedback file by ID
266
+ feedback_file = None
267
+ for file_path in feedback_dir.glob("feedback_*.json"):
268
+ try:
269
+ with open(file_path, 'r', encoding='utf-8') as f:
270
+ data = json.load(f)
271
+ if data.get("id") == feedback_id:
272
+ feedback_file = file_path
273
+ break
274
+ except Exception:
275
+ continue
276
+
277
+ if not feedback_file:
278
+ raise HTTPException(status_code=404, detail="Feedback not found")
279
+
280
+ # Delete the file
281
+ feedback_file.unlink()
282
+ safe_feedback_id = sanitize_for_logging(feedback_id)
283
+ safe_admin_user = sanitize_for_logging(admin_user)
284
+ logger.info(
285
+ "Feedback deleted feedback_id=%s deleted_by=%s",
286
+ safe_feedback_id,
287
+ safe_admin_user,
288
+ )
289
+
290
+ return {
291
+ "message": "Feedback deleted successfully",
292
+ "feedback_id": feedback_id,
293
+ "deleted_by": admin_user
294
+ }
295
+
296
+ except HTTPException:
297
+ raise
298
+ except Exception as e:
299
+ logger.error(f"Error deleting feedback: {e}", exc_info=True)
300
+ raise HTTPException(status_code=500, detail="Failed to delete feedback")
301
+
302
+
303
+ @feedback_router.get("/feedback/download")
304
+ async def download_feedback(
305
+ format: Literal["csv", "json"] = Query(default="csv", description="Download format"),
306
+ admin_user: str = Depends(require_admin_for_feedback)
307
+ ) -> StreamingResponse:
308
+ """Download all feedback data as CSV or JSON (admin only)."""
309
+ try:
310
+ feedback_dir = get_feedback_directory()
311
+
312
+ feedback_files = sorted(
313
+ feedback_dir.glob("feedback_*.json"),
314
+ key=lambda x: x.stat().st_mtime,
315
+ reverse=True
316
+ )
317
+
318
+ all_feedback = []
319
+ for feedback_file in feedback_files:
320
+ try:
321
+ with open(feedback_file, 'r', encoding='utf-8') as f:
322
+ feedback_data = json.load(f)
323
+ all_feedback.append(feedback_data)
324
+ except Exception as e:
325
+ logger.error(f"Error reading feedback file {feedback_file}: {e}")
326
+ continue
327
+
328
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
329
+
330
+ if format == "json":
331
+ content = json.dumps(all_feedback, indent=2, ensure_ascii=False)
332
+ filename = f"feedback_export_{timestamp}.json"
333
+ media_type = "application/json"
334
+ else:
335
+ output = io.StringIO()
336
+ fieldnames = ["id", "timestamp", "user", "rating", "comment"]
337
+ writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction='ignore')
338
+ writer.writeheader()
339
+ for fb in all_feedback:
340
+ writer.writerow({
341
+ "id": fb.get("id", ""),
342
+ "timestamp": fb.get("timestamp", ""),
343
+ "user": fb.get("user", ""),
344
+ "rating": fb.get("rating", ""),
345
+ "comment": fb.get("comment", "")
346
+ })
347
+ content = output.getvalue()
348
+ filename = f"feedback_export_{timestamp}.csv"
349
+ media_type = "text/csv"
350
+
351
+ logger.info(f"Feedback downloaded by {sanitize_for_logging(admin_user)} as {format}")
352
+
353
+ return StreamingResponse(
354
+ iter([content]),
355
+ media_type=media_type,
356
+ headers={"Content-Disposition": f"attachment; filename={filename}"}
357
+ )
358
+
359
+ except Exception as e:
360
+ logger.error(f"Error downloading feedback: {e}", exc_info=True)
361
+ raise HTTPException(status_code=500, detail="Failed to download feedback")
@@ -0,0 +1,274 @@
1
+ """
2
+ Files API routes for S3 file management.
3
+
4
+ Provides REST API endpoints for file operations including upload, download,
5
+ list, delete, and user statistics. Integrates with S3 storage backend.
6
+ """
7
+
8
+ import base64
9
+ import logging
10
+ import re
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from fastapi import APIRouter, Depends, HTTPException, Query, Response
14
+ from pydantic import BaseModel, Field
15
+
16
+ from atlas.core.capabilities import verify_file_token
17
+ from atlas.core.log_sanitizer import get_current_user
18
+ from atlas.core.metrics_logger import log_metric
19
+ from atlas.infrastructure.app_factory import app_factory
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ router = APIRouter(prefix="/api", tags=["files"])
24
+
25
+
26
+ class FileUploadRequest(BaseModel):
27
+ filename: str
28
+ content_base64: str
29
+ content_type: Optional[str] = "application/octet-stream"
30
+ tags: Optional[Dict[str, str]] = Field(default_factory=dict)
31
+
32
+
33
+ class FileResponse(BaseModel):
34
+ key: str
35
+ filename: str
36
+ size: int
37
+ content_type: str
38
+ last_modified: str
39
+ etag: str
40
+ tags: Dict[str, str]
41
+ user_email: str
42
+
43
+
44
+ class FileContentResponse(BaseModel):
45
+ key: str
46
+ filename: str
47
+ content_base64: str
48
+ content_type: str
49
+ size: int
50
+ last_modified: str
51
+ etag: str
52
+ tags: Dict[str, str]
53
+
54
+
55
+ @router.get("/files/healthz")
56
+ async def files_health_check():
57
+ """Health check for files service.
58
+
59
+ Note: Declared before the dynamic /files/{file_key} route to avoid path capture.
60
+ """
61
+ s3_client = app_factory.get_file_storage()
62
+ return {
63
+ "status": "healthy",
64
+ "service": "files-api",
65
+ "s3_config": {
66
+ "endpoint": s3_client.endpoint_url if hasattr(s3_client, 'endpoint_url') else "unknown",
67
+ "bucket": s3_client.bucket_name if hasattr(s3_client, 'bucket_name') else "unknown"
68
+ }
69
+ }
70
+
71
+
72
+ @router.post("/files", response_model=FileResponse)
73
+ async def upload_file(
74
+ request: FileUploadRequest,
75
+ current_user: str = Depends(get_current_user)
76
+ ) -> FileResponse:
77
+ """Upload a file to S3 storage."""
78
+ # Validate base64 content size (configurable limit to prevent abuse)
79
+ try:
80
+ content_size = len(request.content_base64) * 3 // 4 # approximate decoded size
81
+ except Exception:
82
+ raise HTTPException(status_code=400, detail="Invalid base64 content")
83
+
84
+ max_size = 250 * 1024 * 1024 # 250MB default (configurable)
85
+ if content_size > max_size:
86
+ raise HTTPException(status_code=413, detail=f"File too large. Maximum size is {max_size // (1024*1024)}MB")
87
+
88
+ try:
89
+ s3_client = app_factory.get_file_storage()
90
+ result = await s3_client.upload_file(
91
+ user_email=current_user,
92
+ filename=request.filename,
93
+ content_base64=request.content_base64,
94
+ content_type=request.content_type,
95
+ tags=request.tags,
96
+ source_type=request.tags.get("source", "user") if request.tags else "user"
97
+ )
98
+
99
+ log_metric("file_upload", current_user, file_size=content_size, content_type=request.content_type)
100
+
101
+ return FileResponse(**result)
102
+
103
+ except Exception as e:
104
+ logger.error(f"Error uploading file: {str(e)}")
105
+
106
+ log_metric("error", current_user, error_type="file_upload_failed")
107
+
108
+ raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
109
+
110
+
111
+ @router.get("/files/{file_key}", response_model=FileContentResponse)
112
+ async def get_file(
113
+ file_key: str,
114
+ current_user: str = Depends(get_current_user)
115
+ ) -> FileContentResponse:
116
+ """Get a file from S3 storage."""
117
+ try:
118
+ s3_client = app_factory.get_file_storage()
119
+ result = await s3_client.get_file(current_user, file_key)
120
+
121
+ if not result:
122
+ raise HTTPException(status_code=404, detail="File not found")
123
+
124
+ return FileContentResponse(**result)
125
+
126
+ except HTTPException:
127
+ raise
128
+ except Exception as e:
129
+ logger.error(f"Error getting file: {str(e)}")
130
+ if "Access denied" in str(e):
131
+ raise HTTPException(status_code=403, detail="Access denied")
132
+ raise HTTPException(status_code=500, detail=f"Failed to get file: {str(e)}")
133
+
134
+
135
+ @router.get("/files", response_model=List[FileResponse])
136
+ async def list_files(
137
+ current_user: str = Depends(get_current_user),
138
+ file_type: Optional[str] = None,
139
+ limit: int = 100
140
+ ) -> List[FileResponse]:
141
+ """List files for the current user."""
142
+ try:
143
+ s3_client = app_factory.get_file_storage()
144
+ result = await s3_client.list_files(
145
+ user_email=current_user,
146
+ file_type=file_type,
147
+ limit=limit
148
+ )
149
+
150
+ # Convert any datetime objects to ISO format strings for pydantic validation
151
+ processed_files = []
152
+ for file_data in result:
153
+ processed_file = file_data.copy()
154
+ if not isinstance(processed_file.get('last_modified'), str):
155
+ # Convert datetime to ISO format string if it's not already a string
156
+ try:
157
+ processed_file['last_modified'] = processed_file['last_modified'].isoformat()
158
+ except AttributeError:
159
+ # If it's not a datetime object, convert to string
160
+ processed_file['last_modified'] = str(processed_file['last_modified'])
161
+ processed_files.append(processed_file)
162
+
163
+ return [FileResponse(**file_data) for file_data in processed_files]
164
+
165
+ except Exception as e:
166
+ logger.error(f"Error listing files: {str(e)}")
167
+ raise HTTPException(status_code=500, detail=f"Failed to list files: {str(e)}")
168
+
169
+
170
+ @router.delete("/files/{file_key}")
171
+ async def delete_file(
172
+ file_key: str,
173
+ current_user: str = Depends(get_current_user)
174
+ ) -> Dict[str, str]:
175
+ """Delete a file from S3 storage."""
176
+ try:
177
+ s3_client = app_factory.get_file_storage()
178
+ success = await s3_client.delete_file(current_user, file_key)
179
+
180
+ if not success:
181
+ raise HTTPException(status_code=404, detail="File not found")
182
+
183
+ return {"message": "File deleted successfully", "key": file_key}
184
+
185
+ except HTTPException:
186
+ raise
187
+ except Exception as e:
188
+ logger.error(f"Error deleting file: {str(e)}")
189
+ if "Access denied" in str(e):
190
+ raise HTTPException(status_code=403, detail="Access denied")
191
+ raise HTTPException(status_code=500, detail=f"Failed to delete file: {str(e)}")
192
+
193
+
194
+ @router.get("/users/{user_email}/files/stats")
195
+ async def get_user_file_stats(
196
+ user_email: str,
197
+ current_user: str = Depends(get_current_user)
198
+ ) -> Dict[str, Any]:
199
+ """Get file statistics for a user."""
200
+ # Users can only see their own stats
201
+ if current_user != user_email:
202
+ raise HTTPException(status_code=403, detail="Access denied")
203
+
204
+ try:
205
+ s3_client = app_factory.get_file_storage()
206
+ result = await s3_client.get_user_stats(current_user)
207
+ return result
208
+
209
+ except Exception as e:
210
+ logger.error(f"Error getting user stats: {str(e)}")
211
+ raise HTTPException(status_code=500, detail=f"Failed to get stats: {str(e)}")
212
+
213
+
214
+ @router.get("/files/download/{file_key:path}")
215
+ async def download_file(
216
+ file_key: str,
217
+ token: str | None = Query(default=None, description="Capability token for headless access"),
218
+ current_user: str = Depends(get_current_user)
219
+ ):
220
+ """Download a file by key as raw bytes.
221
+
222
+ Returns a binary response with appropriate content type and filename.
223
+ This endpoint is used by the frontend CanvasPanel and can also be used by tools.
224
+ """
225
+ try:
226
+ s3_client = app_factory.get_file_storage()
227
+
228
+ # If token provided, validate and override current_user
229
+ if token:
230
+ claims = verify_file_token(token)
231
+ if not claims or claims.get("k") != file_key:
232
+ raise HTTPException(status_code=403, detail="Invalid token")
233
+ current_user = claims.get("u") or current_user
234
+
235
+ result = await s3_client.get_file(current_user, file_key)
236
+ if not result:
237
+ raise HTTPException(status_code=404, detail="File not found")
238
+
239
+ try:
240
+ raw = base64.b64decode(result["content_base64"]) if result.get("content_base64") else b""
241
+ except Exception:
242
+ raise HTTPException(status_code=500, detail="Corrupted file content")
243
+
244
+ # Sanitize filename for header safety
245
+ fn = result.get('filename', 'download') or 'download'
246
+ # Remove control characters and dangerous bytes
247
+ fn = re.sub(r"[\r\n\t\x00-\x1f\x7f]", "_", fn)
248
+ # Keep it reasonably short
249
+ if len(fn) > 150:
250
+ fn = fn[:150]
251
+
252
+ content_type = result.get("content_type", "application/octet-stream") or "application/octet-stream"
253
+
254
+ # Default to attachment to reduce XSS risk; allow inline only for a small allowlist
255
+ inline_allow = (
256
+ content_type.startswith("image/")
257
+ or content_type.startswith("text/plain")
258
+ or content_type in ("application/pdf",)
259
+ )
260
+ disposition = "inline" if inline_allow else "attachment"
261
+
262
+ headers = {
263
+ "Content-Disposition": f"{disposition}; filename=\"{fn}\"",
264
+ "X-Content-Type-Options": "nosniff",
265
+ }
266
+
267
+ return Response(content=raw, media_type=content_type, headers=headers)
268
+ except HTTPException:
269
+ raise
270
+ except Exception as e:
271
+ logger.error(f"Error downloading file: {str(e)}")
272
+ if "Access denied" in str(e):
273
+ raise HTTPException(status_code=403, detail="Access denied")
274
+ raise HTTPException(status_code=500, detail=f"Failed to download file: {str(e)}")
@@ -0,0 +1,40 @@
1
+ """Health check routes for service monitoring and load balancing.
2
+
3
+ Provides simple health check endpoint for monitoring tools, orchestrators,
4
+ and load balancers to verify service availability.
5
+ """
6
+
7
+ import logging
8
+ from datetime import datetime, timezone
9
+ from typing import Any, Dict
10
+
11
+ from fastapi import APIRouter
12
+
13
+ from atlas.version import VERSION
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ router = APIRouter(prefix="/api", tags=["health"])
18
+
19
+
20
+ @router.get("/health")
21
+ async def health_check() -> Dict[str, Any]:
22
+ """Health check endpoint for service monitoring.
23
+
24
+ Returns basic service status information. This endpoint does not require
25
+ authentication and is intended for use by load balancers, monitoring
26
+ systems, and orchestration platforms.
27
+
28
+ Returns:
29
+ Dictionary containing:
30
+ - status: Service health status ("healthy")
31
+ - service: Service name
32
+ - version: Service version
33
+ - timestamp: Current UTC timestamp in ISO-8601 format
34
+ """
35
+ return {
36
+ "status": "healthy",
37
+ "service": "atlas-ui-3-backend",
38
+ "version": VERSION,
39
+ "timestamp": datetime.now(timezone.utc).isoformat()
40
+ }