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.
- atlas/__init__.py +40 -0
- atlas/application/__init__.py +7 -0
- atlas/application/chat/__init__.py +7 -0
- atlas/application/chat/agent/__init__.py +10 -0
- atlas/application/chat/agent/act_loop.py +179 -0
- atlas/application/chat/agent/factory.py +142 -0
- atlas/application/chat/agent/protocols.py +46 -0
- atlas/application/chat/agent/react_loop.py +338 -0
- atlas/application/chat/agent/think_act_loop.py +171 -0
- atlas/application/chat/approval_manager.py +151 -0
- atlas/application/chat/elicitation_manager.py +191 -0
- atlas/application/chat/events/__init__.py +1 -0
- atlas/application/chat/events/agent_event_relay.py +112 -0
- atlas/application/chat/modes/__init__.py +1 -0
- atlas/application/chat/modes/agent.py +125 -0
- atlas/application/chat/modes/plain.py +74 -0
- atlas/application/chat/modes/rag.py +81 -0
- atlas/application/chat/modes/tools.py +179 -0
- atlas/application/chat/orchestrator.py +213 -0
- atlas/application/chat/policies/__init__.py +1 -0
- atlas/application/chat/policies/tool_authorization.py +99 -0
- atlas/application/chat/preprocessors/__init__.py +1 -0
- atlas/application/chat/preprocessors/message_builder.py +92 -0
- atlas/application/chat/preprocessors/prompt_override_service.py +104 -0
- atlas/application/chat/service.py +454 -0
- atlas/application/chat/utilities/__init__.py +6 -0
- atlas/application/chat/utilities/error_handler.py +367 -0
- atlas/application/chat/utilities/event_notifier.py +546 -0
- atlas/application/chat/utilities/file_processor.py +613 -0
- atlas/application/chat/utilities/tool_executor.py +789 -0
- atlas/atlas_chat_cli.py +347 -0
- atlas/atlas_client.py +238 -0
- atlas/core/__init__.py +0 -0
- atlas/core/auth.py +205 -0
- atlas/core/authorization_manager.py +27 -0
- atlas/core/capabilities.py +123 -0
- atlas/core/compliance.py +215 -0
- atlas/core/domain_whitelist.py +147 -0
- atlas/core/domain_whitelist_middleware.py +82 -0
- atlas/core/http_client.py +28 -0
- atlas/core/log_sanitizer.py +102 -0
- atlas/core/metrics_logger.py +59 -0
- atlas/core/middleware.py +131 -0
- atlas/core/otel_config.py +242 -0
- atlas/core/prompt_risk.py +200 -0
- atlas/core/rate_limit.py +0 -0
- atlas/core/rate_limit_middleware.py +64 -0
- atlas/core/security_headers_middleware.py +51 -0
- atlas/domain/__init__.py +37 -0
- atlas/domain/chat/__init__.py +1 -0
- atlas/domain/chat/dtos.py +85 -0
- atlas/domain/errors.py +96 -0
- atlas/domain/messages/__init__.py +12 -0
- atlas/domain/messages/models.py +160 -0
- atlas/domain/rag_mcp_service.py +664 -0
- atlas/domain/sessions/__init__.py +7 -0
- atlas/domain/sessions/models.py +36 -0
- atlas/domain/unified_rag_service.py +371 -0
- atlas/infrastructure/__init__.py +10 -0
- atlas/infrastructure/app_factory.py +135 -0
- atlas/infrastructure/events/__init__.py +1 -0
- atlas/infrastructure/events/cli_event_publisher.py +140 -0
- atlas/infrastructure/events/websocket_publisher.py +140 -0
- atlas/infrastructure/sessions/in_memory_repository.py +56 -0
- atlas/infrastructure/transport/__init__.py +7 -0
- atlas/infrastructure/transport/websocket_connection_adapter.py +33 -0
- atlas/init_cli.py +226 -0
- atlas/interfaces/__init__.py +15 -0
- atlas/interfaces/events.py +134 -0
- atlas/interfaces/llm.py +54 -0
- atlas/interfaces/rag.py +40 -0
- atlas/interfaces/sessions.py +75 -0
- atlas/interfaces/tools.py +57 -0
- atlas/interfaces/transport.py +24 -0
- atlas/main.py +564 -0
- atlas/mcp/api_key_demo/README.md +76 -0
- atlas/mcp/api_key_demo/main.py +172 -0
- atlas/mcp/api_key_demo/run.sh +56 -0
- atlas/mcp/basictable/main.py +147 -0
- atlas/mcp/calculator/main.py +149 -0
- atlas/mcp/code-executor/execution_engine.py +98 -0
- atlas/mcp/code-executor/execution_environment.py +95 -0
- atlas/mcp/code-executor/main.py +528 -0
- atlas/mcp/code-executor/result_processing.py +276 -0
- atlas/mcp/code-executor/script_generation.py +195 -0
- atlas/mcp/code-executor/security_checker.py +140 -0
- atlas/mcp/corporate_cars/main.py +437 -0
- atlas/mcp/csv_reporter/main.py +545 -0
- atlas/mcp/duckduckgo/main.py +182 -0
- atlas/mcp/elicitation_demo/README.md +171 -0
- atlas/mcp/elicitation_demo/main.py +262 -0
- atlas/mcp/env-demo/README.md +158 -0
- atlas/mcp/env-demo/main.py +199 -0
- atlas/mcp/file_size_test/main.py +284 -0
- atlas/mcp/filesystem/main.py +348 -0
- atlas/mcp/image_demo/main.py +113 -0
- atlas/mcp/image_demo/requirements.txt +4 -0
- atlas/mcp/logging_demo/README.md +72 -0
- atlas/mcp/logging_demo/main.py +103 -0
- atlas/mcp/many_tools_demo/main.py +50 -0
- atlas/mcp/order_database/__init__.py +0 -0
- atlas/mcp/order_database/main.py +369 -0
- atlas/mcp/order_database/signal_data.csv +1001 -0
- atlas/mcp/pdfbasic/main.py +394 -0
- atlas/mcp/pptx_generator/main.py +760 -0
- atlas/mcp/pptx_generator/requirements.txt +13 -0
- atlas/mcp/pptx_generator/run_test.sh +1 -0
- atlas/mcp/pptx_generator/test_pptx_generator_security.py +169 -0
- atlas/mcp/progress_demo/main.py +167 -0
- atlas/mcp/progress_updates_demo/QUICKSTART.md +273 -0
- atlas/mcp/progress_updates_demo/README.md +120 -0
- atlas/mcp/progress_updates_demo/main.py +497 -0
- atlas/mcp/prompts/main.py +222 -0
- atlas/mcp/public_demo/main.py +189 -0
- atlas/mcp/sampling_demo/README.md +169 -0
- atlas/mcp/sampling_demo/main.py +234 -0
- atlas/mcp/thinking/main.py +77 -0
- atlas/mcp/tool_planner/main.py +240 -0
- atlas/mcp/ui-demo/badmesh.png +0 -0
- atlas/mcp/ui-demo/main.py +383 -0
- atlas/mcp/ui-demo/templates/button_demo.html +32 -0
- atlas/mcp/ui-demo/templates/data_visualization.html +32 -0
- atlas/mcp/ui-demo/templates/form_demo.html +28 -0
- atlas/mcp/username-override-demo/README.md +320 -0
- atlas/mcp/username-override-demo/main.py +308 -0
- atlas/modules/__init__.py +0 -0
- atlas/modules/config/__init__.py +34 -0
- atlas/modules/config/cli.py +231 -0
- atlas/modules/config/config_manager.py +1096 -0
- atlas/modules/file_storage/__init__.py +22 -0
- atlas/modules/file_storage/cli.py +330 -0
- atlas/modules/file_storage/content_extractor.py +290 -0
- atlas/modules/file_storage/manager.py +295 -0
- atlas/modules/file_storage/mock_s3_client.py +402 -0
- atlas/modules/file_storage/s3_client.py +417 -0
- atlas/modules/llm/__init__.py +19 -0
- atlas/modules/llm/caller.py +287 -0
- atlas/modules/llm/litellm_caller.py +675 -0
- atlas/modules/llm/models.py +19 -0
- atlas/modules/mcp_tools/__init__.py +17 -0
- atlas/modules/mcp_tools/client.py +2123 -0
- atlas/modules/mcp_tools/token_storage.py +556 -0
- atlas/modules/prompts/prompt_provider.py +130 -0
- atlas/modules/rag/__init__.py +24 -0
- atlas/modules/rag/atlas_rag_client.py +336 -0
- atlas/modules/rag/client.py +129 -0
- atlas/routes/admin_routes.py +865 -0
- atlas/routes/config_routes.py +484 -0
- atlas/routes/feedback_routes.py +361 -0
- atlas/routes/files_routes.py +274 -0
- atlas/routes/health_routes.py +40 -0
- atlas/routes/mcp_auth_routes.py +223 -0
- atlas/server_cli.py +164 -0
- atlas/tests/conftest.py +20 -0
- atlas/tests/integration/test_mcp_auth_integration.py +152 -0
- atlas/tests/manual_test_sampling.py +87 -0
- atlas/tests/modules/mcp_tools/test_client_auth.py +226 -0
- atlas/tests/modules/mcp_tools/test_client_env.py +191 -0
- atlas/tests/test_admin_mcp_server_management_routes.py +141 -0
- atlas/tests/test_agent_roa.py +135 -0
- atlas/tests/test_app_factory_smoke.py +47 -0
- atlas/tests/test_approval_manager.py +439 -0
- atlas/tests/test_atlas_client.py +188 -0
- atlas/tests/test_atlas_rag_client.py +447 -0
- atlas/tests/test_atlas_rag_integration.py +224 -0
- atlas/tests/test_attach_file_flow.py +287 -0
- atlas/tests/test_auth_utils.py +165 -0
- atlas/tests/test_backend_public_url.py +185 -0
- atlas/tests/test_banner_logging.py +287 -0
- atlas/tests/test_capability_tokens_and_injection.py +203 -0
- atlas/tests/test_compliance_level.py +54 -0
- atlas/tests/test_compliance_manager.py +253 -0
- atlas/tests/test_config_manager.py +617 -0
- atlas/tests/test_config_manager_paths.py +12 -0
- atlas/tests/test_core_auth.py +18 -0
- atlas/tests/test_core_utils.py +190 -0
- atlas/tests/test_docker_env_sync.py +202 -0
- atlas/tests/test_domain_errors.py +329 -0
- atlas/tests/test_domain_whitelist.py +359 -0
- atlas/tests/test_elicitation_manager.py +408 -0
- atlas/tests/test_elicitation_routing.py +296 -0
- atlas/tests/test_env_demo_server.py +88 -0
- atlas/tests/test_error_classification.py +113 -0
- atlas/tests/test_error_flow_integration.py +116 -0
- atlas/tests/test_feedback_routes.py +333 -0
- atlas/tests/test_file_content_extraction.py +1134 -0
- atlas/tests/test_file_extraction_routes.py +158 -0
- atlas/tests/test_file_library.py +107 -0
- atlas/tests/test_file_manager_unit.py +18 -0
- atlas/tests/test_health_route.py +49 -0
- atlas/tests/test_http_client_stub.py +8 -0
- atlas/tests/test_imports_smoke.py +30 -0
- atlas/tests/test_interfaces_llm_response.py +9 -0
- atlas/tests/test_issue_access_denied_fix.py +136 -0
- atlas/tests/test_llm_env_expansion.py +836 -0
- atlas/tests/test_log_level_sensitive_data.py +285 -0
- atlas/tests/test_mcp_auth_routes.py +341 -0
- atlas/tests/test_mcp_client_auth.py +331 -0
- atlas/tests/test_mcp_data_injection.py +270 -0
- atlas/tests/test_mcp_get_authorized_servers.py +95 -0
- atlas/tests/test_mcp_hot_reload.py +512 -0
- atlas/tests/test_mcp_image_content.py +424 -0
- atlas/tests/test_mcp_logging.py +172 -0
- atlas/tests/test_mcp_progress_updates.py +313 -0
- atlas/tests/test_mcp_prompt_override_system_prompt.py +102 -0
- atlas/tests/test_mcp_prompts_server.py +39 -0
- atlas/tests/test_mcp_tool_result_parsing.py +296 -0
- atlas/tests/test_metrics_logger.py +56 -0
- atlas/tests/test_middleware_auth.py +379 -0
- atlas/tests/test_prompt_risk_and_acl.py +141 -0
- atlas/tests/test_rag_mcp_aggregator.py +204 -0
- atlas/tests/test_rag_mcp_service.py +224 -0
- atlas/tests/test_rate_limit_middleware.py +45 -0
- atlas/tests/test_routes_config_smoke.py +60 -0
- atlas/tests/test_routes_files_download_token.py +41 -0
- atlas/tests/test_routes_files_health.py +18 -0
- atlas/tests/test_runtime_imports.py +53 -0
- atlas/tests/test_sampling_integration.py +482 -0
- atlas/tests/test_security_admin_routes.py +61 -0
- atlas/tests/test_security_capability_tokens.py +65 -0
- atlas/tests/test_security_file_stats_scope.py +21 -0
- atlas/tests/test_security_header_injection.py +191 -0
- atlas/tests/test_security_headers_and_filename.py +63 -0
- atlas/tests/test_shared_session_repository.py +101 -0
- atlas/tests/test_system_prompt_loading.py +181 -0
- atlas/tests/test_token_storage.py +505 -0
- atlas/tests/test_tool_approval_config.py +93 -0
- atlas/tests/test_tool_approval_utils.py +356 -0
- atlas/tests/test_tool_authorization_group_filtering.py +223 -0
- atlas/tests/test_tool_details_in_config.py +108 -0
- atlas/tests/test_tool_planner.py +300 -0
- atlas/tests/test_unified_rag_service.py +398 -0
- atlas/tests/test_username_override_in_approval.py +258 -0
- atlas/tests/test_websocket_auth_header.py +168 -0
- atlas/version.py +6 -0
- atlas_chat-0.1.0.data/data/.env.example +253 -0
- atlas_chat-0.1.0.data/data/config/defaults/compliance-levels.json +44 -0
- atlas_chat-0.1.0.data/data/config/defaults/domain-whitelist.json +123 -0
- atlas_chat-0.1.0.data/data/config/defaults/file-extractors.json +74 -0
- atlas_chat-0.1.0.data/data/config/defaults/help-config.json +198 -0
- atlas_chat-0.1.0.data/data/config/defaults/llmconfig-buggy.yml +11 -0
- atlas_chat-0.1.0.data/data/config/defaults/llmconfig.yml +19 -0
- atlas_chat-0.1.0.data/data/config/defaults/mcp.json +138 -0
- atlas_chat-0.1.0.data/data/config/defaults/rag-sources.json +17 -0
- atlas_chat-0.1.0.data/data/config/defaults/splash-config.json +16 -0
- atlas_chat-0.1.0.dist-info/METADATA +236 -0
- atlas_chat-0.1.0.dist-info/RECORD +250 -0
- atlas_chat-0.1.0.dist-info/WHEEL +5 -0
- atlas_chat-0.1.0.dist-info/entry_points.txt +4 -0
- 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
|
+
}
|