feat: persist coach sessions to SQLite with disk fallback
This commit is contained in:
1 parent
64a65ca2f6
commit
e7ffccf3e8
2 files changed
+197
-3
No files matched your search
+73
-3
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user