Major changes: - Dashboard: WebSocket-based chat with session management - Dashboard: New chat components (ChatPane, ChatInput, ModeToggle) - Dashboard: Enhanced UI with parallel coordinates chart - MCP Server: New atomizer-tools server for Claude integration - Extractors: Enhanced Zernike OPD extractor - Reports: Improved report generator New studies (configs and scripts only): - M1 Mirror: Cost reduction campaign studies - Simple Beam, Simple Bracket, UAV Arm studies Note: Large iteration data (2_iterations/, best_design_archive/) excluded via .gitignore - kept on local Gitea only. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
296 lines
8.9 KiB
Python
296 lines
8.9 KiB
Python
"""
|
|
Conversation Store
|
|
|
|
SQLite-backed persistence for Claude chat sessions.
|
|
"""
|
|
|
|
import json
|
|
import sqlite3
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
# Database path relative to backend directory
|
|
DB_PATH = Path(__file__).parent.parent.parent / "sessions.db"
|
|
|
|
|
|
class ConversationStore:
|
|
"""SQLite-backed conversation storage for Claude sessions"""
|
|
|
|
def __init__(self, db_path: Path = DB_PATH):
|
|
self.db_path = db_path
|
|
self._init_db()
|
|
|
|
def _get_conn(self) -> sqlite3.Connection:
|
|
"""Get database connection with row factory"""
|
|
conn = sqlite3.connect(self.db_path)
|
|
conn.row_factory = sqlite3.Row
|
|
return conn
|
|
|
|
def _init_db(self):
|
|
"""Initialize database schema"""
|
|
conn = self._get_conn()
|
|
try:
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
session_id TEXT PRIMARY KEY,
|
|
mode TEXT NOT NULL DEFAULT 'user',
|
|
study_id TEXT,
|
|
created_at TEXT NOT NULL,
|
|
last_active TEXT NOT NULL
|
|
)
|
|
""")
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id TEXT NOT NULL,
|
|
role TEXT NOT NULL,
|
|
content TEXT NOT NULL,
|
|
tool_calls TEXT,
|
|
timestamp TEXT NOT NULL,
|
|
FOREIGN KEY (session_id) REFERENCES sessions(session_id)
|
|
)
|
|
""")
|
|
conn.execute("""
|
|
CREATE INDEX IF NOT EXISTS idx_messages_session
|
|
ON messages(session_id)
|
|
""")
|
|
conn.execute("""
|
|
CREATE INDEX IF NOT EXISTS idx_messages_timestamp
|
|
ON messages(session_id, timestamp)
|
|
""")
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
def create_session(
|
|
self,
|
|
session_id: str,
|
|
mode: str = "user",
|
|
study_id: Optional[str] = None,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Create a new session record.
|
|
|
|
Args:
|
|
session_id: Unique session identifier
|
|
mode: "user" or "power"
|
|
study_id: Optional study context
|
|
|
|
Returns:
|
|
Session record dict
|
|
"""
|
|
conn = self._get_conn()
|
|
try:
|
|
now = datetime.now().isoformat()
|
|
conn.execute(
|
|
"""INSERT INTO sessions (session_id, mode, study_id, created_at, last_active)
|
|
VALUES (?, ?, ?, ?, ?)""",
|
|
(session_id, mode, study_id, now, now),
|
|
)
|
|
conn.commit()
|
|
return {
|
|
"session_id": session_id,
|
|
"mode": mode,
|
|
"study_id": study_id,
|
|
"created_at": now,
|
|
"last_active": now,
|
|
}
|
|
finally:
|
|
conn.close()
|
|
|
|
def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:
|
|
"""Get session by ID"""
|
|
conn = self._get_conn()
|
|
try:
|
|
cursor = conn.execute(
|
|
"SELECT * FROM sessions WHERE session_id = ?",
|
|
(session_id,),
|
|
)
|
|
row = cursor.fetchone()
|
|
if row:
|
|
return dict(row)
|
|
return None
|
|
finally:
|
|
conn.close()
|
|
|
|
def update_session(
|
|
self,
|
|
session_id: str,
|
|
mode: Optional[str] = None,
|
|
study_id: Optional[str] = None,
|
|
):
|
|
"""Update session properties"""
|
|
conn = self._get_conn()
|
|
try:
|
|
updates = ["last_active = ?"]
|
|
values: List[Any] = [datetime.now().isoformat()]
|
|
|
|
if mode is not None:
|
|
updates.append("mode = ?")
|
|
values.append(mode)
|
|
if study_id is not None:
|
|
updates.append("study_id = ?")
|
|
values.append(study_id)
|
|
|
|
values.append(session_id)
|
|
conn.execute(
|
|
f"UPDATE sessions SET {', '.join(updates)} WHERE session_id = ?",
|
|
values,
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
def touch_session(self, session_id: str):
|
|
"""Update last_active timestamp"""
|
|
conn = self._get_conn()
|
|
try:
|
|
conn.execute(
|
|
"UPDATE sessions SET last_active = ? WHERE session_id = ?",
|
|
(datetime.now().isoformat(), session_id),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
def add_message(
|
|
self,
|
|
session_id: str,
|
|
role: str,
|
|
content: str,
|
|
tool_calls: Optional[List[Dict]] = None,
|
|
) -> int:
|
|
"""
|
|
Add a message to a session.
|
|
|
|
Args:
|
|
session_id: Session identifier
|
|
role: "user" or "assistant"
|
|
content: Message text content
|
|
tool_calls: Optional list of tool call records
|
|
|
|
Returns:
|
|
Message ID
|
|
"""
|
|
conn = self._get_conn()
|
|
try:
|
|
cursor = conn.execute(
|
|
"""INSERT INTO messages (session_id, role, content, tool_calls, timestamp)
|
|
VALUES (?, ?, ?, ?, ?)""",
|
|
(
|
|
session_id,
|
|
role,
|
|
content,
|
|
json.dumps(tool_calls) if tool_calls else None,
|
|
datetime.now().isoformat(),
|
|
),
|
|
)
|
|
# Update session last_active
|
|
conn.execute(
|
|
"UPDATE sessions SET last_active = ? WHERE session_id = ?",
|
|
(datetime.now().isoformat(), session_id),
|
|
)
|
|
conn.commit()
|
|
return cursor.lastrowid or 0
|
|
finally:
|
|
conn.close()
|
|
|
|
def get_history(
|
|
self,
|
|
session_id: str,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
Get conversation history for a session.
|
|
|
|
Args:
|
|
session_id: Session identifier
|
|
limit: Maximum messages to return
|
|
offset: Number of messages to skip (from most recent)
|
|
|
|
Returns:
|
|
List of message dicts in chronological order
|
|
"""
|
|
conn = self._get_conn()
|
|
try:
|
|
cursor = conn.execute(
|
|
"""
|
|
SELECT role, content, tool_calls, timestamp
|
|
FROM messages
|
|
WHERE session_id = ?
|
|
ORDER BY id DESC
|
|
LIMIT ? OFFSET ?
|
|
""",
|
|
(session_id, limit, offset),
|
|
)
|
|
rows = cursor.fetchall()
|
|
|
|
messages = []
|
|
for row in reversed(rows):
|
|
msg: Dict[str, Any] = {
|
|
"role": row["role"],
|
|
"content": row["content"],
|
|
"timestamp": row["timestamp"],
|
|
}
|
|
if row["tool_calls"]:
|
|
msg["tool_calls"] = json.loads(row["tool_calls"])
|
|
messages.append(msg)
|
|
|
|
return messages
|
|
finally:
|
|
conn.close()
|
|
|
|
def get_message_count(self, session_id: str) -> int:
|
|
"""Get total message count for a session"""
|
|
conn = self._get_conn()
|
|
try:
|
|
cursor = conn.execute(
|
|
"SELECT COUNT(*) FROM messages WHERE session_id = ?",
|
|
(session_id,),
|
|
)
|
|
return cursor.fetchone()[0]
|
|
finally:
|
|
conn.close()
|
|
|
|
def delete_session(self, session_id: str):
|
|
"""Delete a session and all its messages"""
|
|
conn = self._get_conn()
|
|
try:
|
|
conn.execute(
|
|
"DELETE FROM messages WHERE session_id = ?",
|
|
(session_id,),
|
|
)
|
|
conn.execute(
|
|
"DELETE FROM sessions WHERE session_id = ?",
|
|
(session_id,),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
def get_stale_sessions(self, max_age_hours: int = 24) -> List[str]:
|
|
"""Get session IDs that haven't been active for max_age_hours"""
|
|
conn = self._get_conn()
|
|
try:
|
|
cutoff = datetime.now()
|
|
# Calculate cutoff time
|
|
from datetime import timedelta
|
|
cutoff = (cutoff - timedelta(hours=max_age_hours)).isoformat()
|
|
|
|
cursor = conn.execute(
|
|
"SELECT session_id FROM sessions WHERE last_active < ?",
|
|
(cutoff,),
|
|
)
|
|
return [row["session_id"] for row in cursor.fetchall()]
|
|
finally:
|
|
conn.close()
|
|
|
|
def cleanup_stale_sessions(self, max_age_hours: int = 24) -> int:
|
|
"""Delete stale sessions and return count deleted"""
|
|
stale = self.get_stale_sessions(max_age_hours)
|
|
for session_id in stale:
|
|
self.delete_session(session_id)
|
|
return len(stale)
|