truthound-dashboard 1.0.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 (62) hide show
  1. truthound_dashboard/__init__.py +11 -0
  2. truthound_dashboard/__main__.py +6 -0
  3. truthound_dashboard/api/__init__.py +15 -0
  4. truthound_dashboard/api/deps.py +153 -0
  5. truthound_dashboard/api/drift.py +179 -0
  6. truthound_dashboard/api/error_handlers.py +287 -0
  7. truthound_dashboard/api/health.py +78 -0
  8. truthound_dashboard/api/history.py +62 -0
  9. truthound_dashboard/api/middleware.py +626 -0
  10. truthound_dashboard/api/notifications.py +561 -0
  11. truthound_dashboard/api/profile.py +52 -0
  12. truthound_dashboard/api/router.py +83 -0
  13. truthound_dashboard/api/rules.py +277 -0
  14. truthound_dashboard/api/schedules.py +329 -0
  15. truthound_dashboard/api/schemas.py +136 -0
  16. truthound_dashboard/api/sources.py +229 -0
  17. truthound_dashboard/api/validations.py +125 -0
  18. truthound_dashboard/cli.py +226 -0
  19. truthound_dashboard/config.py +132 -0
  20. truthound_dashboard/core/__init__.py +264 -0
  21. truthound_dashboard/core/base.py +185 -0
  22. truthound_dashboard/core/cache.py +479 -0
  23. truthound_dashboard/core/connections.py +331 -0
  24. truthound_dashboard/core/encryption.py +409 -0
  25. truthound_dashboard/core/exceptions.py +627 -0
  26. truthound_dashboard/core/logging.py +488 -0
  27. truthound_dashboard/core/maintenance.py +542 -0
  28. truthound_dashboard/core/notifications/__init__.py +56 -0
  29. truthound_dashboard/core/notifications/base.py +390 -0
  30. truthound_dashboard/core/notifications/channels.py +557 -0
  31. truthound_dashboard/core/notifications/dispatcher.py +453 -0
  32. truthound_dashboard/core/notifications/events.py +155 -0
  33. truthound_dashboard/core/notifications/service.py +744 -0
  34. truthound_dashboard/core/sampling.py +626 -0
  35. truthound_dashboard/core/scheduler.py +311 -0
  36. truthound_dashboard/core/services.py +1531 -0
  37. truthound_dashboard/core/truthound_adapter.py +659 -0
  38. truthound_dashboard/db/__init__.py +67 -0
  39. truthound_dashboard/db/base.py +108 -0
  40. truthound_dashboard/db/database.py +196 -0
  41. truthound_dashboard/db/models.py +732 -0
  42. truthound_dashboard/db/repository.py +237 -0
  43. truthound_dashboard/main.py +309 -0
  44. truthound_dashboard/schemas/__init__.py +150 -0
  45. truthound_dashboard/schemas/base.py +96 -0
  46. truthound_dashboard/schemas/drift.py +118 -0
  47. truthound_dashboard/schemas/history.py +74 -0
  48. truthound_dashboard/schemas/profile.py +91 -0
  49. truthound_dashboard/schemas/rule.py +199 -0
  50. truthound_dashboard/schemas/schedule.py +88 -0
  51. truthound_dashboard/schemas/schema.py +121 -0
  52. truthound_dashboard/schemas/source.py +138 -0
  53. truthound_dashboard/schemas/validation.py +192 -0
  54. truthound_dashboard/static/assets/index-BqJMyAHX.js +110 -0
  55. truthound_dashboard/static/assets/index-DMDxHCTs.js +465 -0
  56. truthound_dashboard/static/assets/index-Dm2D11TK.css +1 -0
  57. truthound_dashboard/static/index.html +15 -0
  58. truthound_dashboard/static/mockServiceWorker.js +349 -0
  59. truthound_dashboard-1.0.0.dist-info/METADATA +218 -0
  60. truthound_dashboard-1.0.0.dist-info/RECORD +62 -0
  61. truthound_dashboard-1.0.0.dist-info/WHEEL +4 -0
  62. truthound_dashboard-1.0.0.dist-info/entry_points.txt +5 -0
@@ -0,0 +1,11 @@
1
+ """Truthound Dashboard - Open-source data quality dashboard.
2
+
3
+ A GX Cloud alternative that provides:
4
+ - Data source management
5
+ - Automated schema learning
6
+ - Data validation and profiling
7
+ - Real-time monitoring dashboard
8
+ """
9
+
10
+ __version__ = "0.1.0"
11
+ __all__ = ["__version__"]
@@ -0,0 +1,6 @@
1
+ """Allow running as python -m truthound_dashboard."""
2
+
3
+ from truthound_dashboard.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
@@ -0,0 +1,15 @@
1
+ """API module.
2
+
3
+ This module contains all API endpoints organized by domain.
4
+
5
+ Routers:
6
+ - health: Health and readiness checks
7
+ - sources: Data source CRUD operations
8
+ - schemas: Schema learning and management
9
+ - validations: Validation execution and history
10
+ - profile: Data profiling
11
+ """
12
+
13
+ from .router import api_router
14
+
15
+ __all__ = ["api_router"]
@@ -0,0 +1,153 @@
1
+ """API dependencies for dependency injection.
2
+
3
+ This module provides FastAPI dependencies for injecting services
4
+ and database sessions into route handlers.
5
+
6
+ Example:
7
+ @router.get("/sources")
8
+ async def list_sources(
9
+ service: SourceService = Depends(get_source_service)
10
+ ):
11
+ return await service.list()
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import AsyncGenerator
17
+ from typing import Annotated
18
+
19
+ from fastapi import Depends
20
+ from sqlalchemy.ext.asyncio import AsyncSession
21
+
22
+ from truthound_dashboard.core import (
23
+ DriftService,
24
+ HistoryService,
25
+ ProfileService,
26
+ RuleService,
27
+ ScheduleService,
28
+ SchemaService,
29
+ SourceService,
30
+ ValidationService,
31
+ )
32
+ from truthound_dashboard.db import get_db_session
33
+
34
+
35
+ async def get_session() -> AsyncGenerator[AsyncSession, None]:
36
+ """Get database session dependency.
37
+
38
+ Yields:
39
+ AsyncSession for database operations.
40
+ """
41
+ async for session in get_db_session():
42
+ yield session
43
+
44
+
45
+ # Type alias for session dependency
46
+ SessionDep = Annotated[AsyncSession, Depends(get_session)]
47
+
48
+
49
+ async def get_source_service(session: SessionDep) -> SourceService:
50
+ """Get source service dependency.
51
+
52
+ Args:
53
+ session: Database session.
54
+
55
+ Returns:
56
+ SourceService instance.
57
+ """
58
+ return SourceService(session)
59
+
60
+
61
+ async def get_validation_service(session: SessionDep) -> ValidationService:
62
+ """Get validation service dependency.
63
+
64
+ Args:
65
+ session: Database session.
66
+
67
+ Returns:
68
+ ValidationService instance.
69
+ """
70
+ return ValidationService(session)
71
+
72
+
73
+ async def get_schema_service(session: SessionDep) -> SchemaService:
74
+ """Get schema service dependency.
75
+
76
+ Args:
77
+ session: Database session.
78
+
79
+ Returns:
80
+ SchemaService instance.
81
+ """
82
+ return SchemaService(session)
83
+
84
+
85
+ async def get_profile_service(session: SessionDep) -> ProfileService:
86
+ """Get profile service dependency.
87
+
88
+ Args:
89
+ session: Database session.
90
+
91
+ Returns:
92
+ ProfileService instance.
93
+ """
94
+ return ProfileService(session)
95
+
96
+
97
+ async def get_rule_service(session: SessionDep) -> RuleService:
98
+ """Get rule service dependency.
99
+
100
+ Args:
101
+ session: Database session.
102
+
103
+ Returns:
104
+ RuleService instance.
105
+ """
106
+ return RuleService(session)
107
+
108
+
109
+ async def get_history_service(session: SessionDep) -> HistoryService:
110
+ """Get history service dependency.
111
+
112
+ Args:
113
+ session: Database session.
114
+
115
+ Returns:
116
+ HistoryService instance.
117
+ """
118
+ return HistoryService(session)
119
+
120
+
121
+ async def get_drift_service(session: SessionDep) -> DriftService:
122
+ """Get drift service dependency.
123
+
124
+ Args:
125
+ session: Database session.
126
+
127
+ Returns:
128
+ DriftService instance.
129
+ """
130
+ return DriftService(session)
131
+
132
+
133
+ async def get_schedule_service(session: SessionDep) -> ScheduleService:
134
+ """Get schedule service dependency.
135
+
136
+ Args:
137
+ session: Database session.
138
+
139
+ Returns:
140
+ ScheduleService instance.
141
+ """
142
+ return ScheduleService(session)
143
+
144
+
145
+ # Type aliases for service dependencies
146
+ SourceServiceDep = Annotated[SourceService, Depends(get_source_service)]
147
+ ValidationServiceDep = Annotated[ValidationService, Depends(get_validation_service)]
148
+ SchemaServiceDep = Annotated[SchemaService, Depends(get_schema_service)]
149
+ ProfileServiceDep = Annotated[ProfileService, Depends(get_profile_service)]
150
+ RuleServiceDep = Annotated[RuleService, Depends(get_rule_service)]
151
+ HistoryServiceDep = Annotated[HistoryService, Depends(get_history_service)]
152
+ DriftServiceDep = Annotated[DriftService, Depends(get_drift_service)]
153
+ ScheduleServiceDep = Annotated[ScheduleService, Depends(get_schedule_service)]
@@ -0,0 +1,179 @@
1
+ """Drift detection API endpoints.
2
+
3
+ Provides endpoints for drift comparison between datasets.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Annotated
9
+
10
+ from fastapi import APIRouter, Depends, HTTPException, Query
11
+
12
+ from truthound_dashboard.core import DriftService
13
+ from truthound_dashboard.schemas import (
14
+ DriftCompareRequest,
15
+ DriftComparisonListResponse,
16
+ )
17
+
18
+ from .deps import SessionDep
19
+
20
+ router = APIRouter()
21
+
22
+
23
+ async def get_drift_service(session: SessionDep) -> DriftService:
24
+ """Get drift service dependency."""
25
+ return DriftService(session)
26
+
27
+
28
+ DriftServiceDep = Annotated[DriftService, Depends(get_drift_service)]
29
+
30
+
31
+ @router.post(
32
+ "/drift/compare",
33
+ response_model=dict,
34
+ summary="Compare datasets for drift",
35
+ description="Compare two datasets to detect data drift.",
36
+ )
37
+ async def compare_datasets(
38
+ request: DriftCompareRequest,
39
+ service: DriftServiceDep,
40
+ ) -> dict:
41
+ """Compare two datasets for drift detection.
42
+
43
+ Args:
44
+ request: Comparison request with source IDs and options.
45
+ service: Drift service.
46
+
47
+ Returns:
48
+ Drift comparison results.
49
+ """
50
+ try:
51
+ comparison = await service.compare(
52
+ baseline_source_id=request.baseline_source_id,
53
+ current_source_id=request.current_source_id,
54
+ columns=request.columns,
55
+ method=request.method,
56
+ threshold=request.threshold,
57
+ sample_size=request.sample_size,
58
+ )
59
+
60
+ return {
61
+ "success": True,
62
+ "data": {
63
+ "id": comparison.id,
64
+ "baseline_source_id": comparison.baseline_source_id,
65
+ "current_source_id": comparison.current_source_id,
66
+ "has_drift": comparison.has_drift,
67
+ "has_high_drift": comparison.has_high_drift,
68
+ "total_columns": comparison.total_columns,
69
+ "drifted_columns": comparison.drifted_columns,
70
+ "drift_percentage": comparison.drift_percentage,
71
+ "result": comparison.result_json,
72
+ "config": comparison.config,
73
+ "created_at": (
74
+ comparison.created_at.isoformat() if comparison.created_at else None
75
+ ),
76
+ },
77
+ }
78
+ except ValueError as e:
79
+ raise HTTPException(status_code=404, detail=str(e))
80
+ except Exception as e:
81
+ raise HTTPException(status_code=500, detail=str(e))
82
+
83
+
84
+ @router.get(
85
+ "/drift/comparisons",
86
+ response_model=DriftComparisonListResponse,
87
+ summary="List drift comparisons",
88
+ description="List all drift comparisons with optional filters.",
89
+ )
90
+ async def list_comparisons(
91
+ service: DriftServiceDep,
92
+ baseline_source_id: str | None = Query(
93
+ None, description="Filter by baseline source"
94
+ ),
95
+ current_source_id: str | None = Query(None, description="Filter by current source"),
96
+ limit: int = Query(20, ge=1, le=100, description="Maximum results"),
97
+ ) -> DriftComparisonListResponse:
98
+ """List drift comparisons.
99
+
100
+ Args:
101
+ service: Drift service.
102
+ baseline_source_id: Optional baseline source ID filter.
103
+ current_source_id: Optional current source ID filter.
104
+ limit: Maximum results to return.
105
+
106
+ Returns:
107
+ List of drift comparisons.
108
+ """
109
+ comparisons = await service.list_comparisons(
110
+ baseline_source_id=baseline_source_id,
111
+ current_source_id=current_source_id,
112
+ limit=limit,
113
+ )
114
+
115
+ return DriftComparisonListResponse(
116
+ success=True,
117
+ data=[
118
+ {
119
+ "id": c.id,
120
+ "baseline_source_id": c.baseline_source_id,
121
+ "current_source_id": c.current_source_id,
122
+ "has_drift": c.has_drift,
123
+ "has_high_drift": c.has_high_drift,
124
+ "total_columns": c.total_columns,
125
+ "drifted_columns": c.drifted_columns,
126
+ "drift_percentage": c.drift_percentage,
127
+ "created_at": c.created_at.isoformat() if c.created_at else None,
128
+ "updated_at": c.updated_at.isoformat() if c.updated_at else None,
129
+ }
130
+ for c in comparisons
131
+ ],
132
+ total=len(comparisons),
133
+ )
134
+
135
+
136
+ @router.get(
137
+ "/drift/comparisons/{comparison_id}",
138
+ response_model=dict,
139
+ summary="Get drift comparison",
140
+ description="Get a specific drift comparison by ID.",
141
+ )
142
+ async def get_comparison(
143
+ comparison_id: str,
144
+ service: DriftServiceDep,
145
+ ) -> dict:
146
+ """Get a drift comparison by ID.
147
+
148
+ Args:
149
+ comparison_id: Comparison ID.
150
+ service: Drift service.
151
+
152
+ Returns:
153
+ Drift comparison details.
154
+ """
155
+ comparison = await service.get_comparison(comparison_id)
156
+ if comparison is None:
157
+ raise HTTPException(status_code=404, detail="Comparison not found")
158
+
159
+ return {
160
+ "success": True,
161
+ "data": {
162
+ "id": comparison.id,
163
+ "baseline_source_id": comparison.baseline_source_id,
164
+ "current_source_id": comparison.current_source_id,
165
+ "has_drift": comparison.has_drift,
166
+ "has_high_drift": comparison.has_high_drift,
167
+ "total_columns": comparison.total_columns,
168
+ "drifted_columns": comparison.drifted_columns,
169
+ "drift_percentage": comparison.drift_percentage,
170
+ "result": comparison.result_json,
171
+ "config": comparison.config,
172
+ "created_at": (
173
+ comparison.created_at.isoformat() if comparison.created_at else None
174
+ ),
175
+ "updated_at": (
176
+ comparison.updated_at.isoformat() if comparison.updated_at else None
177
+ ),
178
+ },
179
+ }
@@ -0,0 +1,287 @@
1
+ """Global error handlers for FastAPI application.
2
+
3
+ This module provides centralized error handling with consistent
4
+ response formats and proper logging.
5
+
6
+ Features:
7
+ - Maps custom exceptions to HTTP responses
8
+ - Handles unexpected errors gracefully
9
+ - Provides consistent error response format
10
+ - Logs errors appropriately
11
+
12
+ Example:
13
+ from truthound_dashboard.api.error_handlers import setup_error_handlers
14
+
15
+ app = FastAPI()
16
+ setup_error_handlers(app)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import traceback
23
+ from typing import Any
24
+
25
+ from fastapi import FastAPI, Request
26
+ from fastapi.exceptions import RequestValidationError
27
+ from fastapi.responses import JSONResponse
28
+ from pydantic import ValidationError as PydanticValidationError
29
+ from starlette.exceptions import HTTPException as StarletteHTTPException
30
+
31
+ from truthound_dashboard.core.exceptions import (
32
+ ErrorCode,
33
+ TruthoundDashboardError,
34
+ get_error_message,
35
+ )
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+
40
+ def create_error_response(
41
+ code: str,
42
+ message: str,
43
+ details: dict[str, Any] | None = None,
44
+ ) -> dict[str, Any]:
45
+ """Create standardized error response.
46
+
47
+ Args:
48
+ code: Error code string.
49
+ message: Human-readable error message.
50
+ details: Additional error details.
51
+
52
+ Returns:
53
+ Dictionary suitable for JSON response.
54
+ """
55
+ response = {
56
+ "success": False,
57
+ "error": {
58
+ "code": code,
59
+ "message": message,
60
+ },
61
+ }
62
+ if details:
63
+ response["error"]["details"] = details
64
+ return response
65
+
66
+
67
+ def setup_error_handlers(app: FastAPI) -> None:
68
+ """Configure global error handlers for FastAPI application.
69
+
70
+ Registers handlers for:
71
+ - TruthoundDashboardError and subclasses
72
+ - Pydantic validation errors
73
+ - FastAPI request validation errors
74
+ - Starlette HTTP exceptions
75
+ - Generic exceptions (catch-all)
76
+
77
+ Args:
78
+ app: FastAPI application instance.
79
+ """
80
+
81
+ @app.exception_handler(TruthoundDashboardError)
82
+ async def handle_dashboard_error(
83
+ request: Request,
84
+ exc: TruthoundDashboardError,
85
+ ) -> JSONResponse:
86
+ """Handle custom dashboard exceptions.
87
+
88
+ Logs the error and returns a structured response.
89
+ """
90
+ # Log at appropriate level based on HTTP status
91
+ if exc.http_status >= 500:
92
+ logger.error(
93
+ f"Dashboard error: {exc.code.value} - {exc.message}",
94
+ extra={"details": exc.details, "path": request.url.path},
95
+ )
96
+ else:
97
+ logger.warning(
98
+ f"Dashboard error: {exc.code.value} - {exc.message}",
99
+ extra={"details": exc.details, "path": request.url.path},
100
+ )
101
+
102
+ return JSONResponse(
103
+ status_code=exc.http_status,
104
+ content=exc.to_response(),
105
+ )
106
+
107
+ @app.exception_handler(RequestValidationError)
108
+ async def handle_request_validation_error(
109
+ request: Request,
110
+ exc: RequestValidationError,
111
+ ) -> JSONResponse:
112
+ """Handle FastAPI request validation errors.
113
+
114
+ Converts Pydantic validation errors to user-friendly format.
115
+ """
116
+ errors = []
117
+ for error in exc.errors():
118
+ location = ".".join(str(loc) for loc in error["loc"])
119
+ errors.append({
120
+ "field": location,
121
+ "message": error["msg"],
122
+ "type": error["type"],
123
+ })
124
+
125
+ logger.warning(
126
+ f"Request validation error: {request.url.path}",
127
+ extra={"errors": errors},
128
+ )
129
+
130
+ return JSONResponse(
131
+ status_code=422,
132
+ content=create_error_response(
133
+ code=ErrorCode.VALIDATION_ERROR.value,
134
+ message="Request validation failed",
135
+ details={"validation_errors": errors},
136
+ ),
137
+ )
138
+
139
+ @app.exception_handler(PydanticValidationError)
140
+ async def handle_pydantic_validation_error(
141
+ request: Request,
142
+ exc: PydanticValidationError,
143
+ ) -> JSONResponse:
144
+ """Handle Pydantic validation errors."""
145
+ errors = []
146
+ for error in exc.errors():
147
+ location = ".".join(str(loc) for loc in error["loc"])
148
+ errors.append({
149
+ "field": location,
150
+ "message": error["msg"],
151
+ "type": error["type"],
152
+ })
153
+
154
+ logger.warning(
155
+ f"Pydantic validation error: {request.url.path}",
156
+ extra={"errors": errors},
157
+ )
158
+
159
+ return JSONResponse(
160
+ status_code=422,
161
+ content=create_error_response(
162
+ code=ErrorCode.VALIDATION_ERROR.value,
163
+ message="Data validation failed",
164
+ details={"validation_errors": errors},
165
+ ),
166
+ )
167
+
168
+ @app.exception_handler(StarletteHTTPException)
169
+ async def handle_http_exception(
170
+ request: Request,
171
+ exc: StarletteHTTPException,
172
+ ) -> JSONResponse:
173
+ """Handle Starlette HTTP exceptions."""
174
+ # Map common HTTP status codes to error codes
175
+ status_code_map = {
176
+ 400: ErrorCode.VALIDATION_ERROR,
177
+ 401: ErrorCode.AUTHENTICATION_REQUIRED,
178
+ 403: ErrorCode.AUTHORIZATION_FAILED,
179
+ 404: ErrorCode.SOURCE_NOT_FOUND,
180
+ 429: ErrorCode.RATE_LIMIT_EXCEEDED,
181
+ 500: ErrorCode.INTERNAL_ERROR,
182
+ 502: ErrorCode.SOURCE_CONNECTION_FAILED,
183
+ 503: ErrorCode.DATABASE_CONNECTION_FAILED,
184
+ }
185
+
186
+ error_code = status_code_map.get(exc.status_code, ErrorCode.UNKNOWN_ERROR)
187
+ message = str(exc.detail) if exc.detail else get_error_message(error_code)
188
+
189
+ if exc.status_code >= 500:
190
+ logger.error(
191
+ f"HTTP error {exc.status_code}: {message}",
192
+ extra={"path": request.url.path},
193
+ )
194
+ else:
195
+ logger.warning(
196
+ f"HTTP error {exc.status_code}: {message}",
197
+ extra={"path": request.url.path},
198
+ )
199
+
200
+ return JSONResponse(
201
+ status_code=exc.status_code,
202
+ content=create_error_response(
203
+ code=error_code.value,
204
+ message=message,
205
+ ),
206
+ )
207
+
208
+ @app.exception_handler(ValueError)
209
+ async def handle_value_error(
210
+ request: Request,
211
+ exc: ValueError,
212
+ ) -> JSONResponse:
213
+ """Handle ValueError as 400 Bad Request."""
214
+ logger.warning(
215
+ f"ValueError: {exc}",
216
+ extra={"path": request.url.path},
217
+ )
218
+
219
+ return JSONResponse(
220
+ status_code=400,
221
+ content=create_error_response(
222
+ code=ErrorCode.VALIDATION_ERROR.value,
223
+ message=str(exc),
224
+ ),
225
+ )
226
+
227
+ @app.exception_handler(FileNotFoundError)
228
+ async def handle_file_not_found_error(
229
+ request: Request,
230
+ exc: FileNotFoundError,
231
+ ) -> JSONResponse:
232
+ """Handle FileNotFoundError as 404 Not Found."""
233
+ logger.warning(
234
+ f"FileNotFoundError: {exc}",
235
+ extra={"path": request.url.path},
236
+ )
237
+
238
+ return JSONResponse(
239
+ status_code=404,
240
+ content=create_error_response(
241
+ code=ErrorCode.SOURCE_NOT_FOUND.value,
242
+ message=str(exc),
243
+ ),
244
+ )
245
+
246
+ @app.exception_handler(PermissionError)
247
+ async def handle_permission_error(
248
+ request: Request,
249
+ exc: PermissionError,
250
+ ) -> JSONResponse:
251
+ """Handle PermissionError as 403 Forbidden."""
252
+ logger.warning(
253
+ f"PermissionError: {exc}",
254
+ extra={"path": request.url.path},
255
+ )
256
+
257
+ return JSONResponse(
258
+ status_code=403,
259
+ content=create_error_response(
260
+ code=ErrorCode.SOURCE_ACCESS_DENIED.value,
261
+ message="Access denied to the requested resource",
262
+ ),
263
+ )
264
+
265
+ @app.exception_handler(Exception)
266
+ async def handle_generic_exception(
267
+ request: Request,
268
+ exc: Exception,
269
+ ) -> JSONResponse:
270
+ """Handle unexpected exceptions.
271
+
272
+ Logs the full traceback but returns a safe error message
273
+ to avoid exposing internal details.
274
+ """
275
+ logger.error(
276
+ f"Unexpected error: {type(exc).__name__}: {exc}\n"
277
+ f"{traceback.format_exc()}",
278
+ extra={"path": request.url.path},
279
+ )
280
+
281
+ return JSONResponse(
282
+ status_code=500,
283
+ content=create_error_response(
284
+ code=ErrorCode.INTERNAL_ERROR.value,
285
+ message=get_error_message(ErrorCode.INTERNAL_ERROR),
286
+ ),
287
+ )