File size: 18,760 Bytes
7e686b5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
import asyncpg
import logging
import time
from typing import Optional, List, Dict, Any
from config import config

logger = logging.getLogger(__name__)


class Database:
    def __init__(self) -> None:
        self.pool: Optional[asyncpg.Pool] = None

    async def connect(self) -> None:
        self.pool = await asyncpg.create_pool(
            dsn=config.DATABASE_URL,
            min_size=1,
            max_size=5,  # ΡƒΠ²Π΅Π»ΠΈΡ‡Π΅Π½ΠΎ для ΠΌΠ°ΡΡˆΡ‚Π°Π±ΠΈΡ€ΡƒΠ΅ΠΌΠΎΡΡ‚ΠΈ
            command_timeout=60,
            server_settings={
                "jit": "off",  # ΠΎΡ‚ΠΊΠ»ΡŽΡ‡Π°Π΅ΠΌ JIT для ΡΡ‚Π°Π±ΠΈΠ»ΡŒΠ½ΠΎΡΡ‚ΠΈ
                "application_name": "glm_bot",
            },
        )
        logger.info("Database pool created (max_size=5)")
        await self._create_tables()
        await self._create_indexes()

    async def disconnect(self) -> None:
        if self.pool:
            await self.pool.close()
            logger.info("Database pool closed")

    def _acquire(self):
        if self.pool is None:
            raise RuntimeError("Database not connected. Call connect() first.")
        return self.pool.acquire()

    async def _create_tables(self) -> None:
        async with self._acquire() as conn:
            # Users table
            await conn.execute("""
                CREATE TABLE IF NOT EXISTS users (
                    id BIGINT PRIMARY KEY,
                    username VARCHAR(255),
                    first_name VARCHAR(255),
                    last_name VARCHAR(255),
                    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
                    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
                    settings JSONB DEFAULT '{}'::jsonb
                )
            """)

            # Messages table
            await conn.execute("""
                CREATE TABLE IF NOT EXISTS messages (
                    id SERIAL PRIMARY KEY,
                    user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
                    role VARCHAR(20) NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
                    content TEXT NOT NULL,
                    tokens_used INTEGER DEFAULT 0,
                    is_summarized BOOLEAN DEFAULT FALSE,
                    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
                )
            """)

            # Summaries table
            await conn.execute("""
                CREATE TABLE IF NOT EXISTS summaries (
                    id SERIAL PRIMARY KEY,
                    user_id BIGINT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
                    summary TEXT NOT NULL,
                    message_count INTEGER NOT NULL DEFAULT 0,
                    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
                    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
                )
            """)

            # Metrics table β€” для ΠΌΠΎΠ½ΠΈΡ‚ΠΎΡ€ΠΈΠ½Π³Π°
            await conn.execute("""
                CREATE TABLE IF NOT EXISTS metrics (
                    id SERIAL PRIMARY KEY,
                    user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
                    model VARCHAR(100),
                    request_duration_ms FLOAT,
                    tokens_input INTEGER DEFAULT 0,
                    tokens_output INTEGER DEFAULT 0,
                    total_tokens INTEGER DEFAULT 0,
                    success BOOLEAN DEFAULT TRUE,
                    error_type VARCHAR(100),
                    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
                )
            """)

            # Rate limiting table
            await conn.execute("""
                CREATE TABLE IF NOT EXISTS rate_limits (
                    user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
                    request_count INTEGER DEFAULT 0,
                    window_start TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
                    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
                )
            """)

            logger.info("Database tables created/verified")

    async def _create_indexes(self) -> None:
        async with self._acquire() as conn:
            await conn.execute("""
                CREATE INDEX IF NOT EXISTS idx_messages_user_id_created_at
                ON messages(user_id, created_at DESC)
            """)
            await conn.execute("""
                CREATE INDEX IF NOT EXISTS idx_messages_user_id_summarized
                ON messages(user_id, is_summarized, created_at DESC)
            """)
            await conn.execute("""
                CREATE INDEX IF NOT EXISTS idx_metrics_user_created
                ON metrics(user_id, created_at DESC)
            """)
            await conn.execute("""
                CREATE INDEX IF NOT EXISTS idx_metrics_created_at
                ON metrics(created_at DESC)
            """)

    # ═══════════════════════════════════════════════════════════════
    # Users
    # ═══════════════════════════════════════════════════════════════
    async def upsert_user(
        self,
        user_id: int,
        username: Optional[str],
        first_name: Optional[str],
        last_name: Optional[str],
    ) -> None:
        async with self._acquire() as conn:
            await conn.execute("""
                INSERT INTO users (id, username, first_name, last_name)
                VALUES ($1, $2, $3, $4)
                ON CONFLICT (id) DO UPDATE SET
                    username = EXCLUDED.username,
                    first_name = EXCLUDED.first_name,
                    last_name = EXCLUDED.last_name,
                    updated_at = NOW()
            """, user_id, username, first_name, last_name)

    async def get_user_settings(self, user_id: int) -> Dict[str, Any]:
        async with self._acquire() as conn:
            row = await conn.fetchrow("""
                SELECT settings FROM users WHERE id = $1
            """, user_id)
            return row["settings"] if row and row["settings"] else {}

    async def update_user_settings(self, user_id: int, settings: Dict[str, Any]) -> None:
        async with self._acquire() as conn:
            await conn.execute("""
                UPDATE users SET settings = $2, updated_at = NOW() WHERE id = $1
            """, user_id, settings)

    # ═══════════════════════════════════════════════════════════════
    # Messages
    # ═══════════════════════════════════════════════════════════════
    async def save_message(
        self, user_id: int, role: str, content: str, tokens_used: int = 0
    ) -> None:
        async with self._acquire() as conn:
            await conn.execute("""
                INSERT INTO messages (user_id, role, content, tokens_used)
                VALUES ($1, $2, $3, $4)
            """, user_id, role, content, tokens_used)

    async def get_messages(self, user_id: int, limit: int = 30) -> List[Dict[str, Any]]:
        async with self._acquire() as conn:
            rows = await conn.fetch("""
                SELECT id, role, content, tokens_used, created_at
                FROM messages
                WHERE user_id = $1 AND is_summarized = FALSE
                ORDER BY created_at DESC
                LIMIT $2
            """, user_id, limit)
            return [
                {
                    "id": r["id"],
                    "role": r["role"],
                    "content": r["content"],
                    "tokens_used": r["tokens_used"],
                    "created_at": r["created_at"],
                }
                for r in reversed(rows)
            ]

    async def get_messages_with_token_budget(
        self, user_id: int, max_tokens: int
    ) -> List[Dict[str, Any]]:
        """Π’ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ сообщСния, ΡƒΠΊΠ»Π°Π΄Ρ‹Π²Π°ΡŽΡ‰ΠΈΠ΅ΡΡ Π² Π±ΡŽΠ΄ΠΆΠ΅Ρ‚ Ρ‚ΠΎΠΊΠ΅Π½ΠΎΠ²."""
        async with self._acquire() as conn:
            rows = await conn.fetch("""
                SELECT id, role, content, tokens_used, created_at
                FROM messages
                WHERE user_id = $1 AND is_summarized = FALSE
                ORDER BY created_at DESC
            """, user_id)

            result = []
            total_tokens = 0
            for r in rows:
                msg_tokens = r["tokens_used"] or len(r["content"].split()) * 2
                if total_tokens + msg_tokens > max_tokens and result:
                    break
                total_tokens += msg_tokens
                result.insert(0, {
                    "id": r["id"],
                    "role": r["role"],
                    "content": r["content"],
                    "tokens_used": r["tokens_used"],
                    "created_at": r["created_at"],
                })
            return result

    # ═══════════════════════════════════════════════════════════════
    # Summaries
    # ═══════════════════════════════════════════════════════════════
    async def get_summary(self, user_id: int) -> Optional[str]:
        async with self._acquire() as conn:
            row = await conn.fetchrow("""
                SELECT summary FROM summaries WHERE user_id = $1
            """, user_id)
            return row["summary"] if row else None

    async def save_summary(self, user_id: int, summary: str, message_count: int) -> None:
        async with self._acquire() as conn:
            await conn.execute("""
                INSERT INTO summaries (user_id, summary, message_count, updated_at)
                VALUES ($1, $2, $3, NOW())
                ON CONFLICT (user_id) DO UPDATE SET
                    summary = EXCLUDED.summary,
                    message_count = summaries.message_count + EXCLUDED.message_count,
                    updated_at = NOW()
            """, user_id, summary, message_count)

    async def mark_summarized(self, user_id: int, cutoff_id: int) -> None:
        async with self._acquire() as conn:
            await conn.execute("""
                UPDATE messages
                SET is_summarized = TRUE
                WHERE user_id = $1 AND id <= $2
            """, user_id, cutoff_id)

    async def get_oldest_unsummarized(self, user_id: int, limit: int) -> List[Dict[str, Any]]:
        async with self._acquire() as conn:
            rows = await conn.fetch("""
                SELECT id, role, content
                FROM messages
                WHERE user_id = $1 AND is_summarized = FALSE
                ORDER BY created_at ASC
                LIMIT $2
            """, user_id, limit)
            return [{"id": r["id"], "role": r["role"], "content": r["content"]} for r in rows]

    async def count_unsummarized(self, user_id: int) -> int:
        async with self._acquire() as conn:
            val = await conn.fetchval("""
                SELECT COUNT(*) FROM messages
                WHERE user_id = $1 AND is_summarized = FALSE
            """, user_id)
            return val or 0

    # ═══════════════════════════════════════════════════════════════
    # Clear History
    # ═══════════════════════════════════════════════════════════════
    async def clear_history(self, user_id: int) -> int:
        async with self._acquire() as conn:
            async with conn.transaction():
                result = await conn.execute("""
                    DELETE FROM messages WHERE user_id = $1
                """, user_id)
                await conn.execute("""
                    DELETE FROM summaries WHERE user_id = $1
                """, user_id)
                try:
                    count = int(result.split()[-1])
                except (ValueError, IndexError):
                    count = 0
                logger.info("Cleared %d messages and summary for user %s", count, user_id)
                return count

    # ═══════════════════════════════════════════════════════════════
    # Stats
    # ═══════════════════════════════════════════════════════════════
    async def get_stats(self, user_id: int) -> Dict[str, Any]:
        async with self._acquire() as conn:
            user_count = await conn.fetchval("SELECT COUNT(*) FROM users")
            msg_count = await conn.fetchval(
                "SELECT COUNT(*) FROM messages WHERE user_id = $1", user_id
            )
            total_msg_count = await conn.fetchval("SELECT COUNT(*) FROM messages")
            summary = await conn.fetchval(
                "SELECT message_count FROM summaries WHERE user_id = $1", user_id
            )
            total_tokens = await conn.fetchval("""
                SELECT COALESCE(SUM(total_tokens), 0) FROM metrics WHERE user_id = $1
            """, user_id)
            avg_latency = await conn.fetchval("""
                SELECT COALESCE(AVG(request_duration_ms), 0)
                FROM metrics WHERE user_id = $1 AND success = TRUE
            """, user_id)
            return {
                "total_users": user_count,
                "user_messages": msg_count,
                "total_messages": total_msg_count,
                "summarized_messages": summary or 0,
                "total_tokens_used": int(total_tokens),
                "avg_latency_ms": round(avg_latency, 1) if avg_latency else 0,
            }

    # ═══════════════════════════════════════════════════════════════
    # Metrics
    # ═══════════════════════════════════════════════════════════════
    async def save_metric(
        self,
        user_id: Optional[int],
        model: str,
        duration_ms: float,
        tokens_input: int = 0,
        tokens_output: int = 0,
        success: bool = True,
        error_type: Optional[str] = None,
    ) -> None:
        async with self._acquire() as conn:
            await conn.execute("""
                INSERT INTO metrics
                (user_id, model, request_duration_ms, tokens_input, tokens_output,
                 total_tokens, success, error_type)
                VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
            """, user_id, model, duration_ms, tokens_input, tokens_output,
                tokens_input + tokens_output, success, error_type)

    async def get_user_metrics(self, user_id: int, limit: int = 50) -> List[Dict[str, Any]]:
        async with self._acquire() as conn:
            rows = await conn.fetch("""
                SELECT model, request_duration_ms, total_tokens, success, error_type, created_at
                FROM metrics WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2
            """, user_id, limit)
            return [dict(r) for r in rows]

    # ═══════════════════════════════════════════════════════════════
    # Rate Limiting
    # ═══════════════════════════════════════════════════════════════
    async def check_rate_limit(self, user_id: int) -> tuple[bool, int, float]:
        """Returns (allowed, remaining_requests, reset_in_seconds)."""
        if not config.RATE_LIMIT_ENABLED:
            return True, 999, 0.0

        async with self._acquire() as conn:
            async with conn.transaction():
                row = await conn.fetchrow("""
                    SELECT request_count, window_start
                    FROM rate_limits WHERE user_id = $1
                    FOR UPDATE
                """, user_id)

                now = time.time()
                window_duration = 60  # 1 minute

                if not row:
                    await conn.execute("""
                        INSERT INTO rate_limits (user_id, request_count, window_start)
                        VALUES ($1, 1, NOW())
                    """, user_id)
                    return True, config.RATE_LIMIT_REQUESTS_PER_MINUTE - 1, window_duration

                window_start_ts = row["window_start"].timestamp()
                if now - window_start_ts >= window_duration:
                    # Window expired, reset
                    await conn.execute("""
                        UPDATE rate_limits
                        SET request_count = 1, window_start = NOW(), updated_at = NOW()
                        WHERE user_id = $1
                    """, user_id)
                    return True, config.RATE_LIMIT_REQUESTS_PER_MINUTE - 1, window_duration

                if row["request_count"] >= config.RATE_LIMIT_REQUESTS_PER_MINUTE:
                    reset_in = window_duration - (now - window_start_ts)
                    return False, 0, reset_in

                await conn.execute("""
                    UPDATE rate_limits
                    SET request_count = request_count + 1, updated_at = NOW()
                    WHERE user_id = $1
                """, user_id)

                remaining = config.RATE_LIMIT_REQUESTS_PER_MINUTE - row["request_count"] - 1
                reset_in = window_duration - (now - window_start_ts)
                return True, remaining, reset_in

    async def reset_rate_limit(self, user_id: int) -> None:
        async with self._acquire() as conn:
            await conn.execute("""
                DELETE FROM rate_limits WHERE user_id = $1
            """, user_id)


db = Database()