File size: 6,478 Bytes
07ab316
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
SQLite session store for the REIC demo UI.

Schema
------
sessions : one row per browser session
turns    : one row per user/assistant turn, linked to session

Auto-expiry
-----------
Sessions inactive for more than SESSION_TTL seconds are deleted.
cleanup_loop() runs in the background every CLEANUP_INTERVAL seconds.
"""

import asyncio
import json
import logging
import time
import uuid
from pathlib import Path

import aiosqlite

logger = logging.getLogger(__name__)

DB_PATH = Path(__file__).parent / "sessions.db"
SESSION_TTL = 3600        # 1 hour in seconds
CLEANUP_INTERVAL = 300    # run cleanup every 5 minutes


# ---------------------------------------------------------------------------
# Schema bootstrap
# ---------------------------------------------------------------------------

async def init_db() -> None:
    async with aiosqlite.connect(DB_PATH) as db:
        await db.execute("""
            CREATE TABLE IF NOT EXISTS sessions (
                id          TEXT PRIMARY KEY,
                created_at  REAL NOT NULL,
                last_active REAL NOT NULL
            )
        """)
        await db.execute("""
            CREATE TABLE IF NOT EXISTS turns (
                id          INTEGER PRIMARY KEY AUTOINCREMENT,
                session_id  TEXT NOT NULL,
                role        TEXT NOT NULL,
                content     TEXT NOT NULL,
                metadata    TEXT,
                ts          REAL NOT NULL
            )
        """)
        await db.execute(
            "CREATE INDEX IF NOT EXISTS idx_turns_session ON turns(session_id)"
        )
        await db.commit()
    logger.info("DB initialised at %s", DB_PATH)


# ---------------------------------------------------------------------------
# Session lifecycle
# ---------------------------------------------------------------------------

async def create_session() -> str:
    sid = str(uuid.uuid4())
    now = time.time()
    async with aiosqlite.connect(DB_PATH) as db:
        await db.execute(
            "INSERT INTO sessions (id, created_at, last_active) VALUES (?, ?, ?)",
            (sid, now, now),
        )
        await db.commit()
    return sid


async def touch_session(db: aiosqlite.Connection, session_id: str) -> None:
    await db.execute(
        "UPDATE sessions SET last_active = ? WHERE id = ?",
        (time.time(), session_id),
    )


async def session_exists(session_id: str) -> bool:
    async with aiosqlite.connect(DB_PATH) as db:
        async with db.execute(
            "SELECT last_active FROM sessions WHERE id = ?", (session_id,)
        ) as cur:
            row = await cur.fetchone()
        if not row:
            return False
        if time.time() - row[0] > SESSION_TTL:
            await _delete_session(db, session_id)
            await db.commit()
            return False
        return True


# ---------------------------------------------------------------------------
# Turn storage & retrieval
# ---------------------------------------------------------------------------

async def append_turn(
    session_id: str,
    role: str,
    content: str,
    metadata: dict | None = None,
) -> None:
    """Add a user or assistant turn and refresh session TTL."""
    async with aiosqlite.connect(DB_PATH) as db:
        await db.execute(
            "INSERT INTO turns (session_id, role, content, metadata, ts) VALUES (?, ?, ?, ?, ?)",
            (session_id, role, content, json.dumps(metadata) if metadata else None, time.time()),
        )
        await touch_session(db, session_id)
        await db.commit()


async def get_history(session_id: str) -> list[dict] | None:
    """
    Return the turn list for a session, or None if the session is gone/expired.

    Each element is a dict with at least {role, content}.
    Assistant turns also include intent_classified (for multi-turn pinning)
    and display fields (confidence, rag_examples, matched_categories, mode, turn_mode).
    """
    async with aiosqlite.connect(DB_PATH) as db:
        async with db.execute(
            "SELECT last_active FROM sessions WHERE id = ?", (session_id,)
        ) as cur:
            row = await cur.fetchone()

        if not row:
            return None
        if time.time() - row[0] > SESSION_TTL:
            await _delete_session(db, session_id)
            await db.commit()
            return None

        async with db.execute(
            "SELECT role, content, metadata FROM turns WHERE session_id = ? ORDER BY ts",
            (session_id,),
        ) as cur:
            rows = await cur.fetchall()

    turns = []
    for role, content, meta_str in rows:
        turn: dict = {"role": role, "content": content}
        if meta_str:
            turn.update(json.loads(meta_str))
        turns.append(turn)
    return turns


async def clear_session_turns(session_id: str) -> None:
    """Delete all turns for a session (keeps the session row, resets TTL)."""
    async with aiosqlite.connect(DB_PATH) as db:
        await db.execute("DELETE FROM turns WHERE session_id = ?", (session_id,))
        await touch_session(db, session_id)
        await db.commit()


# ---------------------------------------------------------------------------
# Cleanup
# ---------------------------------------------------------------------------

async def _delete_session(db: aiosqlite.Connection, session_id: str) -> None:
    await db.execute("DELETE FROM turns    WHERE session_id = ?", (session_id,))
    await db.execute("DELETE FROM sessions WHERE id = ?",         (session_id,))


async def cleanup_expired() -> int:
    """Delete all sessions inactive for more than SESSION_TTL. Returns count deleted."""
    cutoff = time.time() - SESSION_TTL
    async with aiosqlite.connect(DB_PATH) as db:
        async with db.execute(
            "SELECT id FROM sessions WHERE last_active < ?", (cutoff,)
        ) as cur:
            expired = [r[0] for r in await cur.fetchall()]
        for sid in expired:
            await _delete_session(db, sid)
        await db.commit()
    if expired:
        logger.info("Cleaned up %d expired session(s).", len(expired))
    return len(expired)


async def cleanup_loop() -> None:
    """Background coroutine: run cleanup_expired() every CLEANUP_INTERVAL seconds."""
    while True:
        await asyncio.sleep(CLEANUP_INTERVAL)
        try:
            await cleanup_expired()
        except Exception:
            logger.exception("Error during session cleanup")