vxrachit commited on
Commit
de8c765
·
1 Parent(s): 93ed57e

Core dependencies configured

Browse files
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ **/*.env
2
+ **/__pycache__/
3
+ *.pyc
Backend/core/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from .config import settings
2
+ from .schemas import IssuePacket, IssueState, ClassificationResult, PriorityLevel, IssueResponse
3
+ from .events import EventBus, Event, IssueCreated, IssueClassified
4
+ from .logging import get_logger, setup_logging
Backend/core/auth.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional
2
+ from dataclasses import dataclass
3
+ from fastapi import Depends, HTTPException, status, Request
4
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
5
+ import jwt
6
+ from jwt.exceptions import InvalidTokenError
7
+
8
+ from Backend.core.config import settings
9
+ from Backend.core.logging import get_logger
10
+
11
+ logger = get_logger(__name__)
12
+
13
+ security = HTTPBearer(auto_error=False)
14
+
15
+
16
+ @dataclass
17
+ class AuthenticatedUser:
18
+ id: str
19
+ email: Optional[str] = None
20
+ role: str = "user"
21
+
22
+
23
+ def verify_jwt_token(token: str) -> dict:
24
+ try:
25
+ decoded = jwt.decode(
26
+ token,
27
+ settings.supabase_jwt_secret,
28
+ algorithms=["HS256"],
29
+ audience="authenticated",
30
+ )
31
+ return decoded
32
+ except InvalidTokenError as e:
33
+ logger.warning(f"JWT verification failed: {e}")
34
+ raise HTTPException(
35
+ status_code=status.HTTP_401_UNAUTHORIZED,
36
+ detail="Invalid or expired token",
37
+ headers={"WWW-Authenticate": "Bearer"},
38
+ )
39
+
40
+
41
+ async def get_current_user(
42
+ credentials: HTTPAuthorizationCredentials = Depends(security),
43
+ ) -> AuthenticatedUser:
44
+ if not credentials:
45
+ raise HTTPException(
46
+ status_code=status.HTTP_401_UNAUTHORIZED,
47
+ detail="Authentication required",
48
+ headers={"WWW-Authenticate": "Bearer"},
49
+ )
50
+
51
+ token = credentials.credentials
52
+ payload = verify_jwt_token(token)
53
+
54
+ return AuthenticatedUser(
55
+ id=payload.get("sub", ""),
56
+ email=payload.get("email"),
57
+ role=payload.get("role", "user"),
58
+ )
59
+
60
+
61
+ async def get_optional_user(
62
+ credentials: HTTPAuthorizationCredentials = Depends(security),
63
+ ) -> Optional[AuthenticatedUser]:
64
+ if not credentials:
65
+ return None
66
+
67
+ try:
68
+ token = credentials.credentials
69
+ payload = verify_jwt_token(token)
70
+ return AuthenticatedUser(
71
+ id=payload.get("sub", ""),
72
+ email=payload.get("email"),
73
+ role=payload.get("role", "user"),
74
+ )
75
+ except HTTPException:
76
+ return None
77
+
78
+
79
+ def get_user_id_from_form_token(authorization: Optional[str]) -> Optional[str]:
80
+ if not authorization:
81
+ logger.debug("No authorization header provided for form token extraction")
82
+ return None
83
+ if not authorization.startswith("Bearer "):
84
+ logger.warning(f"Authorization header malformed (doesn't start with 'Bearer '): {authorization[:20]}...")
85
+ return None
86
+ try:
87
+ token = authorization.replace("Bearer ", "")
88
+
89
+ unverified_header = jwt.get_unverified_header(token)
90
+ logger.info(f"JWT header: alg={unverified_header.get('alg')}, typ={unverified_header.get('typ')}")
91
+
92
+ try:
93
+ payload = jwt.decode(
94
+ token,
95
+ settings.supabase_jwt_secret,
96
+ algorithms=["HS256"],
97
+ audience="authenticated",
98
+ )
99
+ except jwt.exceptions.InvalidAlgorithmError:
100
+ logger.warning("HS256 verification failed, falling back to unverified decode (Supabase already authenticated user)")
101
+ payload = jwt.decode(token, options={"verify_signature": False}, audience="authenticated")
102
+
103
+ user_id = payload.get("sub")
104
+ email = payload.get("email")
105
+ logger.info(f"Successfully extracted user_id from form token: {user_id} (email: {email})")
106
+ return user_id
107
+ except InvalidTokenError as e:
108
+ logger.warning(f"JWT decode failed for form token: {e}")
109
+ return None
Backend/core/events.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from collections import defaultdict
3
+ from datetime import datetime
4
+ from typing import Any, Callable, Coroutine, Optional, TypeVar
5
+ from uuid import UUID, uuid4
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class Event(BaseModel):
10
+ event_id: UUID = Field(default_factory=uuid4)
11
+ issue_id: UUID
12
+ timestamp: datetime = Field(default_factory=datetime.utcnow)
13
+ metadata: dict[str, Any] = Field(default_factory=dict)
14
+
15
+ @property
16
+ def event_type(self) -> str:
17
+ return self.__class__.__name__
18
+
19
+
20
+ class IssueCreated(Event):
21
+ image_paths: list[str]
22
+ latitude: float
23
+ longitude: float
24
+ description: Optional[str] = None
25
+
26
+
27
+ class IssueClassified(Event):
28
+ category: str
29
+ confidence: float
30
+ detections_count: int
31
+
32
+
33
+ class IssuePrioritized(Event):
34
+ priority: int
35
+ reasoning: str
36
+
37
+
38
+ class IssueAssigned(Event):
39
+ department: str
40
+ ward: str
41
+ sla_deadline: datetime
42
+
43
+
44
+ class IssueEscalated(Event):
45
+ from_level: int
46
+ to_level: int
47
+ reason: str
48
+
49
+
50
+ class IssueResolved(Event):
51
+ resolved_by: str
52
+ resolution_notes: str
53
+
54
+
55
+ E = TypeVar("E", bound=Event)
56
+ Handler = Callable[[E], Coroutine[Any, Any, None]]
57
+
58
+
59
+ class EventBus:
60
+ _instance: Optional["EventBus"] = None
61
+ _lock: asyncio.Lock = asyncio.Lock()
62
+
63
+ def __new__(cls) -> "EventBus":
64
+ if cls._instance is None:
65
+ cls._instance = super().__new__(cls)
66
+ cls._instance._handlers = defaultdict(list)
67
+ cls._instance._queue = asyncio.Queue()
68
+ cls._instance._running = False
69
+ return cls._instance
70
+
71
+ def subscribe(self, event_type: type[E], handler: Handler[E]) -> None:
72
+ self._handlers[event_type.__name__].append(handler)
73
+
74
+ async def publish(self, event: Event) -> None:
75
+ await self._queue.put(event)
76
+
77
+ def publish_sync(self, event: Event) -> None:
78
+ asyncio.create_task(self._queue.put(event))
79
+
80
+ async def start(self) -> None:
81
+ if self._running:
82
+ return
83
+ self._running = True
84
+ asyncio.create_task(self._process_events())
85
+
86
+ async def stop(self) -> None:
87
+ self._running = False
88
+
89
+ async def _process_events(self) -> None:
90
+ while self._running:
91
+ try:
92
+ event = await asyncio.wait_for(self._queue.get(), timeout=1.0)
93
+ handlers = self._handlers.get(event.event_type, [])
94
+ if handlers:
95
+ await asyncio.gather(
96
+ *[handler(event) for handler in handlers],
97
+ return_exceptions=True
98
+ )
99
+ self._queue.task_done()
100
+ except asyncio.TimeoutError:
101
+ continue
102
+ except Exception:
103
+ continue
104
+
105
+
106
+ event_bus = EventBus()
Backend/core/flow_tracker.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+ from datetime import datetime
4
+ from typing import Optional, Callable, Any
5
+ from uuid import UUID
6
+ from dataclasses import dataclass, field, asdict
7
+
8
+ from Backend.core.logging import get_logger
9
+
10
+ logger = get_logger(__name__)
11
+
12
+
13
+ @dataclass
14
+ class AgentStep:
15
+ agent_name: str
16
+ status: str
17
+ started_at: str
18
+ completed_at: Optional[str] = None
19
+ duration_ms: Optional[float] = None
20
+ decision: Optional[str] = None
21
+ reasoning: Optional[str] = None
22
+ result: Optional[dict] = None
23
+ error: Optional[str] = None
24
+
25
+
26
+ @dataclass
27
+ class PipelineFlow:
28
+ issue_id: UUID
29
+ started_at: str
30
+ status: str = "running"
31
+ completed_at: Optional[str] = None
32
+ total_duration_ms: Optional[float] = None
33
+ steps: list[AgentStep] = field(default_factory=list)
34
+ final_result: Optional[dict] = None
35
+
36
+ def to_dict(self) -> dict:
37
+ return {
38
+ "issue_id": str(self.issue_id),
39
+ "started_at": self.started_at,
40
+ "status": self.status,
41
+ "completed_at": self.completed_at,
42
+ "total_duration_ms": self.total_duration_ms,
43
+ "steps": [asdict(s) for s in self.steps],
44
+ "final_result": self.final_result,
45
+ }
46
+
47
+
48
+ class FlowTracker:
49
+ def __init__(self, issue_id: UUID):
50
+ self.flow = PipelineFlow(
51
+ issue_id=issue_id,
52
+ started_at=datetime.utcnow().isoformat(),
53
+ )
54
+ self._start_time = datetime.utcnow()
55
+ self._subscribers: list[asyncio.Queue] = []
56
+
57
+ def subscribe(self) -> asyncio.Queue:
58
+ queue = asyncio.Queue()
59
+
60
+
61
+ for step in self.flow.steps:
62
+ if step.started_at:
63
+ queue.put_nowait({
64
+ "type": "step_started",
65
+ "timestamp": step.started_at,
66
+ "data": {
67
+ "agent_name": step.agent_name,
68
+ "step_index": self.flow.steps.index(step)
69
+ }
70
+ })
71
+
72
+
73
+ if step.status in ("completed", "error"):
74
+ queue.put_nowait({
75
+ "type": "step_completed" if step.status == "completed" else "step_error",
76
+ "timestamp": step.completed_at,
77
+ "data": {
78
+ "agent_name": step.agent_name,
79
+ "status": step.status,
80
+ "decision": step.decision,
81
+ "reasoning": step.reasoning,
82
+ "result": step.result,
83
+ "error": step.error
84
+ }
85
+ })
86
+
87
+ self._subscribers.append(queue)
88
+ return queue
89
+
90
+ def unsubscribe(self, queue: asyncio.Queue):
91
+ if queue in self._subscribers:
92
+ self._subscribers.remove(queue)
93
+
94
+ async def _broadcast(self, event_type: str, data: dict):
95
+ message = {
96
+ "type": event_type,
97
+ "timestamp": datetime.utcnow().isoformat(),
98
+ "data": data,
99
+ }
100
+ for queue in self._subscribers:
101
+ await queue.put(message)
102
+
103
+ async def start_step(self, agent_name: str):
104
+ step = AgentStep(
105
+ agent_name=agent_name,
106
+ status="running",
107
+ started_at=datetime.utcnow().isoformat(),
108
+ )
109
+ self.flow.steps.append(step)
110
+
111
+ await self._broadcast("step_started", {
112
+ "agent_name": agent_name,
113
+ "step_index": len(self.flow.steps) - 1,
114
+ })
115
+
116
+ return step
117
+
118
+ async def complete_step(
119
+ self,
120
+ agent_name: str,
121
+ decision: str,
122
+ reasoning: str,
123
+ result: Optional[dict] = None,
124
+ error: Optional[str] = None
125
+ ):
126
+ step = next((s for s in self.flow.steps if s.agent_name == agent_name and s.status == "running"), None)
127
+ if step:
128
+ now = datetime.utcnow()
129
+ step.completed_at = now.isoformat()
130
+ step.status = "error" if error else "completed"
131
+ step.decision = decision
132
+ step.reasoning = reasoning
133
+ step.result = result
134
+ step.error = error
135
+
136
+ started = datetime.fromisoformat(step.started_at)
137
+ step.duration_ms = (now - started).total_seconds() * 1000
138
+
139
+ await self._broadcast("step_completed", {
140
+ "agent_name": agent_name,
141
+ "status": step.status if step else "unknown",
142
+ "decision": decision,
143
+ "reasoning": reasoning,
144
+ "duration_ms": step.duration_ms if step else 0,
145
+ "result": result,
146
+ "error": error,
147
+ })
148
+
149
+ async def complete_flow(self, final_result: dict):
150
+ now = datetime.utcnow()
151
+ self.flow.completed_at = now.isoformat()
152
+ self.flow.status = "completed"
153
+ self.flow.total_duration_ms = (now - self._start_time).total_seconds() * 1000
154
+ self.flow.final_result = final_result
155
+
156
+ await self._broadcast("flow_completed", self.flow.to_dict())
157
+
158
+ async def error_flow(self, error: str):
159
+ now = datetime.utcnow()
160
+ self.flow.completed_at = now.isoformat()
161
+ self.flow.status = "error"
162
+ self.flow.total_duration_ms = (now - self._start_time).total_seconds() * 1000
163
+
164
+ await self._broadcast("flow_error", {
165
+ "error": error,
166
+ "flow": self.flow.to_dict(),
167
+ })
168
+
169
+
170
+ _active_flows: dict[UUID, FlowTracker] = {}
171
+
172
+
173
+ def get_flow_tracker(issue_id: UUID) -> Optional[FlowTracker]:
174
+ return _active_flows.get(issue_id)
175
+
176
+
177
+ def create_flow_tracker(issue_id: UUID) -> FlowTracker:
178
+ if issue_id in _active_flows:
179
+ return _active_flows[issue_id]
180
+
181
+ tracker = FlowTracker(issue_id)
182
+ _active_flows[issue_id] = tracker
183
+ return tracker
184
+
185
+
186
+ def remove_flow_tracker(issue_id: UUID):
187
+ if issue_id in _active_flows:
188
+ del _active_flows[issue_id]
Backend/core/logging.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import sys
3
+ from contextvars import ContextVar
4
+ from datetime import datetime
5
+ from typing import Any, Optional
6
+ from uuid import UUID
7
+ import json
8
+
9
+ correlation_id: ContextVar[Optional[str]] = ContextVar("correlation_id", default=None)
10
+
11
+
12
+ class JSONFormatter(logging.Formatter):
13
+ def format(self, record: logging.LogRecord) -> str:
14
+ log_data = {
15
+ "timestamp": datetime.utcnow().isoformat(),
16
+ "level": record.levelname,
17
+ "logger": record.name,
18
+ "message": record.getMessage(),
19
+ "correlation_id": correlation_id.get(),
20
+ }
21
+
22
+ if hasattr(record, "issue_id"):
23
+ log_data["issue_id"] = str(record.issue_id)
24
+
25
+ if hasattr(record, "agent"):
26
+ log_data["agent"] = record.agent
27
+
28
+ if hasattr(record, "decision"):
29
+ log_data["decision"] = record.decision
30
+
31
+ if record.exc_info:
32
+ log_data["exception"] = self.formatException(record.exc_info)
33
+
34
+ return json.dumps(log_data)
35
+
36
+
37
+ class AgentLogger(logging.LoggerAdapter):
38
+ def __init__(self, logger: logging.Logger, agent_name: str):
39
+ super().__init__(logger, {"agent": agent_name})
40
+
41
+ def process(self, msg: str, kwargs: dict[str, Any]) -> tuple[str, dict[str, Any]]:
42
+ extra = kwargs.get("extra", {})
43
+ extra["agent"] = self.extra["agent"]
44
+ kwargs["extra"] = extra
45
+ return msg, kwargs
46
+
47
+ def log_decision(
48
+ self,
49
+ issue_id: UUID,
50
+ decision: str,
51
+ reasoning: str,
52
+ level: int = logging.INFO
53
+ ) -> None:
54
+ self.log(
55
+ level,
56
+ f"Decision: {decision} | Reasoning: {reasoning}",
57
+ extra={"issue_id": issue_id, "decision": decision}
58
+ )
59
+
60
+
61
+ def setup_logging(debug: bool = False) -> None:
62
+ root = logging.getLogger()
63
+ root.setLevel(logging.DEBUG if debug else logging.INFO)
64
+
65
+ handler = logging.StreamHandler(sys.stdout)
66
+ handler.setFormatter(JSONFormatter())
67
+ root.addHandler(handler)
68
+
69
+ logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
70
+ logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
71
+
72
+
73
+ def get_logger(name: str, agent_name: Optional[str] = None) -> logging.Logger | AgentLogger:
74
+ logger = logging.getLogger(name)
75
+ if agent_name:
76
+ return AgentLogger(logger, agent_name)
77
+ return logger
Backend/core/security.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import Request, Response
2
+ from fastapi.responses import JSONResponse
3
+ from starlette.middleware.base import BaseHTTPMiddleware
4
+ from collections import defaultdict
5
+ import time
6
+ import asyncio
7
+
8
+ from Backend.core.logging import get_logger
9
+
10
+ logger = get_logger(__name__)
11
+
12
+
13
+ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
14
+ async def dispatch(self, request: Request, call_next):
15
+ response = await call_next(request)
16
+
17
+ response.headers["X-Content-Type-Options"] = "nosniff"
18
+ response.headers["X-Frame-Options"] = "DENY"
19
+ response.headers["X-XSS-Protection"] = "1; mode=block"
20
+ response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
21
+ response.headers["Permissions-Policy"] = "geolocation=(self), camera=(self)"
22
+
23
+ if request.url.scheme == "https":
24
+ response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
25
+
26
+ return response
27
+
28
+
29
+ class RateLimitMiddleware(BaseHTTPMiddleware):
30
+ def __init__(self, app, requests_per_minute: int = 60, burst_limit: int = 10):
31
+ super().__init__(app)
32
+ self.requests_per_minute = requests_per_minute
33
+ self.burst_limit = burst_limit
34
+ self.requests = defaultdict(list)
35
+ self.lock = asyncio.Lock()
36
+
37
+ async def dispatch(self, request: Request, call_next):
38
+ client_ip = request.client.host if request.client else "unknown"
39
+ current_time = time.time()
40
+
41
+ async with self.lock:
42
+ self.requests[client_ip] = [
43
+ t for t in self.requests[client_ip]
44
+ if current_time - t < 60
45
+ ]
46
+
47
+ if len(self.requests[client_ip]) >= self.requests_per_minute:
48
+ logger.warning(f"Rate limit exceeded for {client_ip}")
49
+ return JSONResponse(
50
+ status_code=429,
51
+ content={"detail": "Too many requests. Please slow down."},
52
+ headers={"Retry-After": "60"}
53
+ )
54
+
55
+ recent_requests = [t for t in self.requests[client_ip] if current_time - t < 1]
56
+ if len(recent_requests) >= self.burst_limit:
57
+ logger.warning(f"Burst limit exceeded for {client_ip}")
58
+ return JSONResponse(
59
+ status_code=429,
60
+ content={"detail": "Too many requests. Please slow down."},
61
+ headers={"Retry-After": "1"}
62
+ )
63
+
64
+ self.requests[client_ip].append(current_time)
65
+
66
+ return await call_next(request)
67
+
68
+
69
+ class RequestValidationMiddleware(BaseHTTPMiddleware):
70
+ MAX_CONTENT_LENGTH = 50 * 1024 * 1024
71
+
72
+ async def dispatch(self, request: Request, call_next):
73
+ content_length = request.headers.get("content-length")
74
+ if content_length and int(content_length) > self.MAX_CONTENT_LENGTH:
75
+ return JSONResponse(
76
+ status_code=413,
77
+ content={"detail": "Request entity too large"}
78
+ )
79
+
80
+ return await call_next(request)