diff --git a/coach/coach_service.py b/coach/coach_service.py index f2bf4fa..7fff6ae 100644 --- a/coach/coach_service.py +++ b/coach/coach_service.py @@ -31,6 +31,18 @@ from .prompt_template import build_system_prompt, build_user_prompt logger = logging.getLogger("coach.service") +# SQLite persistence for coach sessions +try: + from services.database import ( + save_coach_session as _db_save_session, + load_coach_session as _db_load_session, + list_coach_sessions as _db_list_sessions, + delete_coach_session as _db_delete_session, + ) + _DB_AVAILABLE = True +except ImportError: + _DB_AVAILABLE = False + class CoachService: """ @@ -83,15 +95,62 @@ class CoachService: # ── Coach Session I/O ────────────────────────────────────── def save_session(self, session: CoachSession) -> Path: - """Persist a coaching session to disk.""" + """Persist a coaching session to disk and SQLite.""" + # 1. Write JSON file (backward compat) COACH_SESSIONS_DIR.mkdir(parents=True, exist_ok=True) path = COACH_SESSIONS_DIR / f"{session.sessionId}.json" with open(path, "w") as f: f.write(session.model_dump_json(indent=2)) + # 2. Write to SQLite + if _DB_AVAILABLE: + try: + _db_save_session({ + "session_id": session.sessionId, + "deck_id": session.deckId, + "timestamp": session.timestamp, + "model_used": session.modelUsed or "", + "prompt_tokens": session.promptTokens or 0, + "completion_tokens": session.completionTokens or 0, + "summary": session.summary or "", + "goals_json": json.dumps(session.goals.model_dump() if session.goals else {}), + "cuts_json": json.dumps([c.model_dump() for c in session.suggestedCuts]), + "adds_json": json.dumps([a.model_dump() for a in session.suggestedAdds]), + "heuristic_hints_json": json.dumps(session.heuristicHints or []), + "mana_base_advice": session.manaBaseAdvice or "", + "raw_text": session.rawTextExplanation or "", + }) + logger.info("Coach session persisted to SQLite: %s", session.sessionId) + except Exception as e: + logger.warning("Failed to persist session to SQLite: %s", e) return path + def load_session(self, session_id: str) -> Optional[CoachSession]: - """Load a coaching session from disk.""" + """Load a coaching session from SQLite, falling back to disk.""" + # Try SQLite first + if _DB_AVAILABLE: + try: + row = _db_load_session(session_id) + if row: + data = dict(row) + return CoachSession( + sessionId=data["session_id"], + deckId=data["deck_id"], + timestamp=data["timestamp"], + modelUsed=data.get("model_used", ""), + promptTokens=data.get("prompt_tokens", 0), + completionTokens=data.get("completion_tokens", 0), + summary=data.get("summary", ""), + goals=json.loads(data["goals_json"]) if data.get("goals_json") and data["goals_json"] != "{}" else None, + suggestedCuts=[SuggestedCut(**c) for c in json.loads(data.get("cuts_json", "[]"))], + suggestedAdds=[SuggestedAdd(**a) for a in json.loads(data.get("adds_json", "[]"))], + heuristicHints=json.loads(data.get("heuristic_hints_json", "[]")), + manaBaseAdvice=data.get("mana_base_advice", ""), + rawTextExplanation=data.get("raw_text", ""), + ) + except Exception as e: + logger.warning("SQLite load failed for %s: %s", session_id, e) + # Fallback to disk path = COACH_SESSIONS_DIR / f"{session_id}.json" if not path.exists(): return None @@ -103,8 +162,18 @@ class CoachService: logger.error("Failed to load session %s: %s", session_id, e) return None + def list_sessions(self, deck_id: str = None) -> List[dict]: - """List all coaching sessions, optionally filtered by deck.""" + """List coaching sessions from SQLite, falling back to disk.""" + # Try SQLite first + if _DB_AVAILABLE: + try: + rows = _db_list_sessions(deck_id) + if rows is not None: + return rows + except Exception as e: + logger.warning("SQLite list_sessions failed: %s", e) + # Fallback to disk if not COACH_SESSIONS_DIR.exists(): return [] sessions = [] @@ -126,6 +195,7 @@ class CoachService: continue return sessions + # ── Main Coaching Pipeline ───────────────────────────────── async def run_coaching_session(self, deck_id: str, diff --git a/services/database.py b/services/database.py index 1b893f3..b5c97b1 100644 --- a/services/database.py +++ b/services/database.py @@ -136,6 +136,28 @@ def init_collection_db() -> None: FOREIGN KEY (deck_id) REFERENCES decks(id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS coach_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL UNIQUE, + deck_id TEXT NOT NULL, + timestamp TEXT NOT NULL, + model_used TEXT DEFAULT '', + prompt_tokens INTEGER DEFAULT 0, + completion_tokens INTEGER DEFAULT 0, + summary TEXT DEFAULT '', + goals_json TEXT DEFAULT '{}', + cuts_json TEXT DEFAULT '[]', + adds_json TEXT DEFAULT '[]', + heuristic_hints_json TEXT DEFAULT '[]', + mana_base_advice TEXT DEFAULT '', + raw_text TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE INDEX IF NOT EXISTS idx_cs_session_id ON coach_sessions(session_id); + CREATE INDEX IF NOT EXISTS idx_cs_deck_id ON coach_sessions(deck_id); + CREATE INDEX IF NOT EXISTS idx_cs_timestamp ON coach_sessions(timestamp); + CREATE INDEX IF NOT EXISTS idx_dc_deck_id ON deck_cards(deck_id); CREATE INDEX IF NOT EXISTS idx_dc_scryfall_id ON deck_cards(scryfall_id); CREATE INDEX IF NOT EXISTS idx_dk_commander ON decks(commander_scryfall_id); @@ -302,3 +324,105 @@ def _build_collection_filters( conditions.append("quantity <= ?"); params.append(qtyMax) where_str = ("WHERE " + " AND ".join(conditions)) if conditions else "" return where_str, params + + +# ══════════════════════════════════════════════════════════════ +# Coach Session DB Helpers +# ══════════════════════════════════════════════════════════════ + + +def save_coach_session(session_data: dict) -> int: + """Insert or update a coach session in SQLite. Returns the row id.""" + conn = _get_db_conn() + existing = conn.execute( + "SELECT id FROM coach_sessions WHERE session_id = ?", + (session_data["session_id"],), + ).fetchone() + if existing: + conn.execute( + """UPDATE coach_sessions SET + deck_id=?, timestamp=?, model_used=?, prompt_tokens=?, + completion_tokens=?, summary=?, goals_json=?, cuts_json=?, + adds_json=?, heuristic_hints_json=?, mana_base_advice=?, + raw_text=? + WHERE session_id=?""", + ( + session_data.get("deck_id", ""), + session_data.get("timestamp", ""), + session_data.get("model_used", ""), + session_data.get("prompt_tokens", 0), + session_data.get("completion_tokens", 0), + session_data.get("summary", ""), + session_data.get("goals_json", "{}"), + session_data.get("cuts_json", "[]"), + session_data.get("adds_json", "[]"), + session_data.get("heuristic_hints_json", "[]"), + session_data.get("mana_base_advice", ""), + session_data.get("raw_text", ""), + session_data["session_id"], + ), + ) + conn.commit() + return existing["id"] + else: + cur = conn.execute( + """INSERT INTO coach_sessions + (session_id, deck_id, timestamp, model_used, prompt_tokens, + completion_tokens, summary, goals_json, cuts_json, adds_json, + heuristic_hints_json, mana_base_advice, raw_text) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + session_data["session_id"], + session_data.get("deck_id", ""), + session_data.get("timestamp", ""), + session_data.get("model_used", ""), + session_data.get("prompt_tokens", 0), + session_data.get("completion_tokens", 0), + session_data.get("summary", ""), + session_data.get("goals_json", "{}"), + session_data.get("cuts_json", "[]"), + session_data.get("adds_json", "[]"), + session_data.get("heuristic_hints_json", "[]"), + session_data.get("mana_base_advice", ""), + session_data.get("raw_text", ""), + ), + ) + conn.commit() + return cur.lastrowid + + +def load_coach_session(session_id: str) -> Optional[dict]: + """Load a single coach session by session_id.""" + conn = _get_db_conn() + row = conn.execute( + "SELECT * FROM coach_sessions WHERE session_id = ?", (session_id,) + ).fetchone() + return dict(row) if row else None + + +def list_coach_sessions(deck_id: str = None) -> list: + """List coach sessions, optionally filtered by deck_id.""" + conn = _get_db_conn() + if deck_id: + rows = conn.execute( + "SELECT session_id, deck_id, timestamp, summary, created_at " + "FROM coach_sessions WHERE deck_id = ? ORDER BY timestamp DESC", + (deck_id,), + ).fetchall() + else: + rows = conn.execute( + "SELECT session_id, deck_id, timestamp, summary, created_at " + "FROM coach_sessions ORDER BY timestamp DESC" + ).fetchall() + return [dict(r) for r in rows] + + +def delete_coach_session(session_id: str) -> bool: + """Delete a coach session. Returns True if a row was deleted.""" + conn = _get_db_conn() + cur = conn.execute( + "DELETE FROM coach_sessions WHERE session_id = ?", (session_id,) + ) + conn.commit() + return cur.rowcount > 0 +