| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382 |
- from app.schemas import UserCreate, PersonaCreate, PersonaUpdate, ForumCreate, MessageCreate
- from app.core.hashing import Hasher
- from app.db.client import fetch_one, fetch_all, RowObject, db_transaction, db_execute_commit
- from app.core.cache import cache_service
- import json
- import logging
- from typing import List, Optional, Any
- from datetime import datetime
- logger = logging.getLogger(__name__)
- def _normalize_persona(persona):
- if persona and isinstance(getattr(persona, "theories", None), str):
- try:
- theories = json.loads(persona.theories)
- if isinstance(theories, list):
- persona.theories = theories
- except json.JSONDecodeError:
- pass
- return persona
- # --- Cache Keys ---
- def user_cache_key(username: str): return f"user:{username}"
- def persona_cache_key(pid: int): return f"persona:{pid}"
- def forum_cache_key(fid: int): return f"forum:{fid}"
- def forum_participants_cache_key(fid: int): return f"forum:{fid}:participants"
- # --- User ---
- def get_user_by_username(db, username: str):
- # Cache Aside: Read
- cache_key = user_cache_key(username)
- cached = cache_service.get_cache(cache_key)
- if cached:
- return RowObject(cached) # Convert dict back to RowObject-like
- rs = db.execute("SELECT * FROM users WHERE username = ?", [username])
- user = fetch_one(rs)
-
- if user:
- cache_service.set_cache(cache_key, user.__dict__, expire=3600)
-
- return user
- def create_user(db: Any, user: UserCreate):
- password_bytes = user.password.encode('utf-8')
- if len(password_bytes) > 71:
- password_bytes = password_bytes[:71]
- safe_password = password_bytes.decode('utf-8', 'ignore')
-
- try:
- # Use transaction to ensure commit
- pwd_hash = Hasher.get_password_hash(safe_password)
- created_at = datetime.now()
- rs = db_execute_commit(
- db,
- "INSERT INTO users (username, email, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?) RETURNING *",
- [user.username, user.email, pwd_hash, user.role, created_at]
- )
- new_user = fetch_one(rs)
-
- if new_user:
- cache_service.set_cache(user_cache_key(new_user.username), new_user.__dict__, expire=3600)
- return new_user
- except Exception as e:
- logger.error(f"Error creating user: {e}")
- raise
- # --- Persona ---
- def create_persona(db, persona: PersonaCreate, owner_id: int):
- try:
- theories_json = json.dumps(persona.theories)
- created_at = datetime.now()
- rs = db_execute_commit(
- db,
- """
- INSERT INTO personas (owner_id, name, title, bio, theories, stance, system_prompt, is_public, created_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
- RETURNING *
- """,
- [
- owner_id,
- persona.name,
- persona.title,
- persona.bio,
- theories_json,
- persona.stance,
- persona.system_prompt,
- persona.is_public,
- created_at
- ]
- )
- new_persona = fetch_one(rs)
-
- # Cache Aside: Don't set cache on create. Let the first read populate it.
- # This ensures strict adherence to "DB is source of truth" and lazy loading.
-
- return _normalize_persona(new_persona)
- except Exception as e:
- logger.error(f"Error creating persona: {e}")
- raise
- def get_persona(db, persona_id: int):
- cache_key = persona_cache_key(persona_id)
- cached = cache_service.get_cache(cache_key)
- if cached:
- return _normalize_persona(RowObject(cached))
- rs = db.execute("SELECT * FROM personas WHERE id = ?", [persona_id])
- persona = fetch_one(rs)
- persona = _normalize_persona(persona)
- if persona:
- cache_service.set_cache(cache_key, persona.__dict__)
- return persona
- def update_persona(db, persona_id: int, updates: PersonaUpdate):
- try:
- update_data = updates.model_dump(exclude_unset=True)
- if not update_data:
- return get_persona(db, persona_id)
- set_clauses = []
- values = []
- for key, value in update_data.items():
- set_clauses.append(f"{key} = ?")
- if key == "theories":
- values.append(json.dumps(value))
- else:
- values.append(value)
-
- values.append(persona_id)
- query = f"UPDATE personas SET {', '.join(set_clauses)} WHERE id = ? RETURNING *"
-
- rs = db_execute_commit(db, query, values)
- updated = fetch_one(rs)
-
- # Sync Strategy: Delete Redis Key on Update
- if updated:
- cache_service.delete_cache(persona_cache_key(persona_id))
-
- return _normalize_persona(updated)
- except Exception as e:
- logger.error(f"Error updating persona: {e}")
- raise
- def delete_persona(db, persona_id: int):
- try:
- # Check if exists first to ensure idempotency and clear error
- rs_check = db.execute("SELECT id FROM personas WHERE id = ?", [persona_id])
- if not fetch_one(rs_check):
- return True # Already deleted or not exists
- with db_transaction(db) as tx:
- # Manually set persona_id to NULL in messages to avoid FK violation
- tx.execute("UPDATE messages SET persona_id = NULL WHERE persona_id = ?", [persona_id])
-
- # Cascading deletes should be handled by DB foreign keys,
- # but let's be explicit if needed or just execute
- rs = tx.execute("DELETE FROM personas WHERE id = ?", [persona_id])
-
- # FORCE COMMIT
- if hasattr(tx, 'commit'):
- tx.commit()
- elif hasattr(db, 'commit'):
- db.commit()
-
- # Sync Strategy: Delete Redis Key on Delete
- cache_service.delete_cache(persona_cache_key(persona_id))
-
- return True
- except Exception as e:
- logger.error(f"Error deleting persona {persona_id}: {e}")
- raise
- # --- Forum ---
- def create_forum(db, forum: ForumCreate, creator_id: int):
- try:
- with db_transaction(db) as tx:
- rs = tx.execute(
- """
- INSERT INTO forums (topic, creator_id, moderator_id, status, duration_minutes, start_time, summary_history, ablation_flags)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
- RETURNING *
- """,
- [
- forum.topic,
- creator_id,
- forum.moderator_id,
- "pending",
- forum.duration_minutes,
- None,
- "[]",
- "{}"
- ]
- )
- db_forum = fetch_one(rs)
- tx.execute("DELETE FROM messages WHERE forum_id = ?", [db_forum.id])
- tx.execute("DELETE FROM forum_participants WHERE forum_id = ?", [db_forum.id])
- tx.execute("DELETE FROM system_logs WHERE forum_id = ?", [db_forum.id])
- if forum.participant_ids:
- unique_pids = list(dict.fromkeys(int(pid) for pid in forum.participant_ids))
- values = []
- placeholders = []
- for pid in unique_pids:
- placeholders.append("(?, ?, ?)")
- values.extend([db_forum.id, pid, "[]"])
- if values:
- query = f"INSERT INTO forum_participants (forum_id, persona_id, thoughts_history) VALUES {', '.join(placeholders)} ON CONFLICT (forum_id, persona_id) DO NOTHING"
- tx.execute(query, values)
-
- # FORCE COMMIT
- if hasattr(tx, 'commit'):
- tx.commit()
- elif hasattr(db, 'commit'):
- db.commit()
- # Return full object (will trigger cache set in get_forum)
- return get_forum(db, db_forum.id)
- except Exception as e:
- logger.error(f"Error creating forum: {e}")
- raise
- def delete_forum(db, forum_id: int):
- logger.info(f"Attempting to delete forum {forum_id}")
- try:
- with db_transaction(db) as tx:
- tx.execute("DELETE FROM messages WHERE forum_id = ?", [forum_id])
- tx.execute("DELETE FROM forum_participants WHERE forum_id = ?", [forum_id])
- tx.execute("DELETE FROM system_logs WHERE forum_id = ?", [forum_id])
- rs = tx.execute("DELETE FROM forums WHERE id = ?", [forum_id])
-
- affected = rs.rows_affected if hasattr(rs, 'rows_affected') else -1
- logger.info(f"Deleted forum {forum_id}, rows affected: {affected}")
-
- # FORCE COMMIT
- if hasattr(tx, 'commit'):
- tx.commit()
- logger.info("Transaction committed explicitly")
- elif hasattr(db, 'commit'):
- db.commit()
- logger.info("DB committed explicitly")
-
- success = affected > 0 if affected != -1 else True
-
- return success
- except Exception as e:
- logger.error(f"Error deleting forum: {e}")
- raise
- def get_forum(db, forum_id: int):
- rs = db.execute("SELECT * FROM forums WHERE id = ?", [forum_id])
- forum = fetch_one(rs)
- if not forum:
- return None
-
- participants = get_forum_participants(db, forum_id)
- setattr(forum, "participants", participants)
-
- if forum.moderator_id:
- mod_rs = db.execute("SELECT * FROM moderators WHERE id = ?", [forum.moderator_id])
- setattr(forum, "moderator", fetch_one(mod_rs))
- else:
- setattr(forum, "moderator", None)
-
- return forum
- def update_forum(
- db,
- forum_id: int,
- summary_history: list = None,
- status: str = None,
- start_time: datetime = None,
- ablation_flags: dict = None,
- ):
- try:
- set_clauses = []
- values = []
-
- if summary_history is not None:
- set_clauses.append("summary_history = ?")
- values.append(json.dumps(summary_history))
-
- if status is not None:
- set_clauses.append("status = ?")
- values.append(status)
- if start_time is not None:
- set_clauses.append("start_time = ?")
- values.append(start_time)
- if ablation_flags is not None:
- set_clauses.append("ablation_flags = ?")
- values.append(json.dumps(ablation_flags))
-
- if not set_clauses:
- return get_forum(db, forum_id)
-
- values.append(forum_id)
- query = f"UPDATE forums SET {', '.join(set_clauses)} WHERE id = ? RETURNING *"
-
- rs = db_execute_commit(db, query, values)
- updated = fetch_one(rs)
-
- return updated
- except Exception as e:
- logger.error(f"Error updating forum: {e}")
- raise
- def get_forum_participants(db, forum_id: int):
- query = """
- SELECT fp.*, p.name as persona_name, p.title as persona_title, p.bio as persona_bio,
- p.theories as persona_theories, p.stance as persona_stance,
- p.system_prompt as persona_system_prompt, p.owner_id as persona_owner_id,
- p.created_at as persona_created_at
- FROM forum_participants fp
- JOIN personas p ON fp.persona_id = p.id
- WHERE fp.forum_id = ?
- """
- rs = db.execute(query, [forum_id])
- rows = fetch_all(rs)
-
- results = []
- for row in rows:
- persona_data = {
- "id": row.persona_id,
- "name": row.persona_name,
- "title": row.persona_title,
- "bio": row.persona_bio,
- "theories": row.persona_theories,
- "stance": row.persona_stance,
- "system_prompt": row.persona_system_prompt,
- "owner_id": row.persona_owner_id,
- "created_at": row.persona_created_at
- }
- setattr(row, "persona", RowObject(persona_data))
- results.append(row)
- return results
- def update_forum_participant(db, forum_id: int, persona_id: int, thoughts_history: list = None):
- try:
- if thoughts_history is None:
- return None
-
- query = "UPDATE forum_participants SET thoughts_history = ? WHERE forum_id = ? AND persona_id = ? RETURNING *"
- rs = db_execute_commit(db, query, [json.dumps(thoughts_history), forum_id, persona_id])
- return fetch_one(rs)
- except Exception as e:
- logger.error(f"Error updating participant: {e}")
- raise
- def create_message(db, message: MessageCreate):
- try:
- timestamp = datetime.now()
- rs = db_execute_commit(
- db,
- """
- INSERT INTO messages (forum_id, persona_id, moderator_id, speaker_name, content, turn_count, thought, timestamp)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
- RETURNING *
- """,
- [
- message.forum_id,
- message.persona_id,
- message.moderator_id,
- message.speaker_name,
- message.content,
- message.turn_count,
- message.thought,
- timestamp
- ]
- )
- return fetch_one(rs)
- except Exception as e:
- logger.error(f"Error creating message: {e}")
- raise
- def get_forum_messages(db, forum_id: int):
- rs = db.execute("SELECT * FROM messages WHERE forum_id = ? ORDER BY timestamp ASC", [forum_id])
- return fetch_all(rs)
|