Renecto commited on
Commit
8b4e99f
·
verified ·
1 Parent(s): e93af4a

upload: session_store.py

Browse files
Files changed (1) hide show
  1. session_store.py +111 -0
session_store.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ InMemory session manager.
3
+ - Thread-safe via Lock
4
+ - TTL-based lazy eviction
5
+ - Bounded by MAX_SESSIONS
6
+ """
7
+
8
+ from __future__ import annotations
9
+ import os
10
+ import threading
11
+ import uuid
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime, timedelta
14
+ from typing import Optional
15
+
16
+ SESSION_TTL_SEC = int(os.environ.get("SESSION_TTL_SEC", "1800"))
17
+ MAX_SESSIONS = int(os.environ.get("MAX_SESSIONS", "1000"))
18
+ MAX_TURNS = 50
19
+
20
+
21
+ @dataclass
22
+ class AccumulatedContext:
23
+ campaign_name: Optional[str] = None
24
+ industry: Optional[str] = None
25
+ cvr: Optional[float] = None
26
+ ctr: Optional[float] = None
27
+ cpa: Optional[float] = None
28
+ image_base64: Optional[str] = None
29
+
30
+ def merge(self, ctx: "AccumulatedContext") -> None:
31
+ """Merge new values in -- never overwrites with None."""
32
+ if ctx.campaign_name is not None:
33
+ self.campaign_name = ctx.campaign_name
34
+ if ctx.industry is not None:
35
+ self.industry = ctx.industry
36
+ if ctx.cvr is not None:
37
+ self.cvr = ctx.cvr
38
+ if ctx.ctr is not None:
39
+ self.ctr = ctx.ctr
40
+ if ctx.cpa is not None:
41
+ self.cpa = ctx.cpa
42
+ if ctx.image_base64 is not None:
43
+ self.image_base64 = ctx.image_base64
44
+
45
+
46
+ @dataclass
47
+ class HistoryEntry:
48
+ role: str # "user" | "assistant"
49
+ content: str
50
+ timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat())
51
+
52
+
53
+ @dataclass
54
+ class SessionState:
55
+ session_id: str
56
+ created_at: datetime = field(default_factory=datetime.utcnow)
57
+ last_accessed: datetime = field(default_factory=datetime.utcnow)
58
+ turn_count: int = 0
59
+ accumulated_context: AccumulatedContext = field(default_factory=AccumulatedContext)
60
+ history: list[HistoryEntry] = field(default_factory=list)
61
+ current_level: str = "level1"
62
+
63
+ def is_expired(self) -> bool:
64
+ return datetime.utcnow() - self.last_accessed > timedelta(seconds=SESSION_TTL_SEC)
65
+
66
+ def touch(self) -> None:
67
+ self.last_accessed = datetime.utcnow()
68
+
69
+
70
+ class SessionStore:
71
+ def __init__(self) -> None:
72
+ self._sessions: dict[str, SessionState] = {}
73
+ self._lock = threading.Lock()
74
+
75
+ def create(self) -> SessionState:
76
+ with self._lock:
77
+ self._evict_expired()
78
+ if len(self._sessions) >= MAX_SESSIONS:
79
+ raise RuntimeError("MAX_SESSIONS limit reached")
80
+ session_id = str(uuid.uuid4())
81
+ state = SessionState(session_id=session_id)
82
+ self._sessions[session_id] = state
83
+ return state
84
+
85
+ def get(self, session_id: str) -> Optional[SessionState]:
86
+ with self._lock:
87
+ state = self._sessions.get(session_id)
88
+ if state is None:
89
+ return None
90
+ if state.is_expired():
91
+ del self._sessions[session_id]
92
+ return None
93
+ state.touch()
94
+ return state
95
+
96
+ def save(self, state: SessionState) -> None:
97
+ with self._lock:
98
+ self._sessions[state.session_id] = state
99
+
100
+ def _evict_expired(self) -> None:
101
+ expired = [sid for sid, s in self._sessions.items() if s.is_expired()]
102
+ for sid in expired:
103
+ del self._sessions[sid]
104
+
105
+ def count(self) -> int:
106
+ with self._lock:
107
+ return len(self._sessions)
108
+
109
+
110
+ # Singleton
111
+ store = SessionStore()