fix: eliminate event-loop-blocking sync calls in async FastAPI routes (#106)
* feat: add async DB wrapper and eliminate event-loop blocking * fix: rewrite llm_client.py with native async httpx - removes urlopen and run_in_executor * fix: rewrite routes/coach.py - replace all sync DB/HTTP with async (aiosqlite + httpx) * fix: add async DB shutdown hook to lab_api.py * feat: add aiosqlite>=0.19.0 to requirements
This commit is contained in:
1 parent
cf7c872e12
commit
af2a71d1a5
5 files changed
+776
-724
No files matched your search
+127
-130
@@ -1,22 +1,25 @@
|
||||
"""
|
||||
Commander AI Lab — Ollama Client
|
||||
Commander AI Lab — Ollama LLM Client
|
||||
════════════════════════════════════
|
||||
Async HTTP client targeting Ollama's OpenAI-compatible
|
||||
/v1/chat/completions endpoint. Includes retry logic for
|
||||
non-JSON responses and configurable timeout.
|
||||
Fully async HTTP client targeting Ollama's OpenAI-compatible
|
||||
/v1/chat/completions endpoint. Every network call uses
|
||||
httpx.AsyncClient so it NEVER blocks the FastAPI event loop.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
from urllib.request import urlopen, Request
|
||||
from urllib.error import URLError
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import (
|
||||
LLM_URL, LLM_MODEL,
|
||||
LLM_TIMEOUT, LLM_MAX_RETRIES,
|
||||
DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS,
|
||||
LLM_URL,
|
||||
LLM_MODEL,
|
||||
LLM_TIMEOUT,
|
||||
LLM_MAX_RETRIES,
|
||||
DEFAULT_TEMPERATURE,
|
||||
DEFAULT_MAX_TOKENS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("coach.llm")
|
||||
@@ -24,9 +27,15 @@ logger = logging.getLogger("coach.llm")
|
||||
|
||||
class LLMResponse:
|
||||
"""Parsed LLM response container."""
|
||||
def __init__(self, content: str, parsed_json: Optional[dict],
|
||||
prompt_tokens: int, completion_tokens: int,
|
||||
model: str):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
content: str,
|
||||
parsed_json: Optional[dict],
|
||||
prompt_tokens: int,
|
||||
completion_tokens: int,
|
||||
model: str,
|
||||
):
|
||||
self.content = content
|
||||
self.parsed_json = parsed_json
|
||||
self.prompt_tokens = prompt_tokens
|
||||
@@ -36,7 +45,7 @@ class LLMResponse:
|
||||
|
||||
class LLMClient:
|
||||
"""
|
||||
Client for Ollama's OpenAI-compatible API.
|
||||
Fully async client for Ollama's OpenAI-compatible API.
|
||||
|
||||
Usage:
|
||||
client = LLMClient()
|
||||
@@ -45,19 +54,25 @@ class LLMClient:
|
||||
suggestions = response.parsed_json
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str = None, model: str = None,
|
||||
timeout: int = None):
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = None,
|
||||
model: str = None,
|
||||
timeout: int = None,
|
||||
):
|
||||
self.base_url = (base_url or LLM_URL).rstrip("/")
|
||||
self.model = model or LLM_MODEL
|
||||
self.timeout = timeout or LLM_TIMEOUT
|
||||
self._resolved_model = None # actual model name from /models endpoint
|
||||
self._resolved_model: Optional[str] = None
|
||||
|
||||
def _resolve_model(self) -> str:
|
||||
# ── Model resolution ───────────────────────────────────────
|
||||
|
||||
async def _resolve_model(self) -> str:
|
||||
"""Get the actual loaded model name from Ollama /models endpoint."""
|
||||
if self._resolved_model:
|
||||
return self._resolved_model
|
||||
try:
|
||||
status = self.check_connection()
|
||||
status = await self.check_connection()
|
||||
if status.get("connected") and status.get("active_model"):
|
||||
self._resolved_model = status["active_model"]
|
||||
logger.info("Resolved Ollama model: %s", self._resolved_model)
|
||||
@@ -66,11 +81,18 @@ class LLMClient:
|
||||
pass
|
||||
return self.model
|
||||
|
||||
def _build_request_body(self, system_prompt: str, user_prompt: str,
|
||||
temperature: float, max_tokens: int,
|
||||
force_json: bool) -> dict:
|
||||
model_name = self._resolve_model()
|
||||
body = {
|
||||
# ── Request building ──────────────────────────────────────
|
||||
|
||||
async def _build_request_body(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
force_json: bool,
|
||||
) -> dict:
|
||||
model_name = await self._resolve_model()
|
||||
body: dict = {
|
||||
"model": model_name,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
@@ -79,32 +101,27 @@ class LLMClient:
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
# Only request JSON format if using a model that supports it
|
||||
# Some models (esp. smaller ones) don't support response_format
|
||||
if force_json:
|
||||
body["response_format"] = {"type": "json_object"}
|
||||
return body
|
||||
|
||||
# ── Response parsing ──────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _strip_think_tags(text: str) -> str:
|
||||
"""Remove DeepSeek-R1 <think>...</think> reasoning blocks from LLM output."""
|
||||
"""Remove DeepSeek-R1 <think>...</think> reasoning blocks."""
|
||||
import re
|
||||
# Remove all <think>...</think> blocks (greedy, handles multiline)
|
||||
cleaned = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL)
|
||||
return cleaned.strip()
|
||||
return re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL).strip()
|
||||
|
||||
@staticmethod
|
||||
def _extract_json_object(text: str) -> Optional[dict]:
|
||||
"""Find and parse the first complete JSON object in text.
|
||||
Uses brace-counting to handle nested objects reliably."""
|
||||
"""Find and parse the first complete JSON object via brace-counting."""
|
||||
start = text.find('{')
|
||||
if start == -1:
|
||||
return None
|
||||
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape_next = False
|
||||
|
||||
for i in range(start, len(text)):
|
||||
ch = text[i]
|
||||
if escape_next:
|
||||
@@ -124,66 +141,56 @@ class LLMClient:
|
||||
elif ch == '}':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
candidate = text[start:i+1]
|
||||
candidate = text[start : i + 1]
|
||||
try:
|
||||
return json.loads(candidate)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
# Malformed — try next opening brace
|
||||
next_start = text.find('{', start + 1)
|
||||
if next_start != -1 and next_start < i:
|
||||
start = next_start
|
||||
depth = 1 # we're already past this brace
|
||||
return None
|
||||
depth = 1
|
||||
return None
|
||||
return None
|
||||
|
||||
def _parse_response(self, raw: dict) -> LLMResponse:
|
||||
"""Extract content and usage from OpenAI-format response.
|
||||
Handles DeepSeek-R1 <think> tags, markdown fences, and raw JSON."""
|
||||
"""Extract content and usage from an OpenAI-format response."""
|
||||
choice = raw.get("choices", [{}])[0]
|
||||
content = choice.get("message", {}).get("content", "")
|
||||
usage = raw.get("usage", {})
|
||||
|
||||
# Step 1: Strip <think>...</think> blocks (DeepSeek-R1)
|
||||
cleaned = self._strip_think_tags(content)
|
||||
|
||||
# Step 2: Try direct JSON parse on cleaned content
|
||||
parsed = None
|
||||
parsed: Optional[dict] = None
|
||||
# 1. Direct JSON parse
|
||||
try:
|
||||
parsed = json.loads(cleaned)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Step 3: Try extracting from markdown fences
|
||||
# 2. Markdown fences
|
||||
if parsed is None and "```json" in cleaned:
|
||||
try:
|
||||
start = cleaned.index("```json") + 7
|
||||
end = cleaned.index("```", start)
|
||||
parsed = json.loads(cleaned[start:end].strip())
|
||||
s = cleaned.index("```json") + 7
|
||||
e = cleaned.index("```", s)
|
||||
parsed = json.loads(cleaned[s:e].strip())
|
||||
except (json.JSONDecodeError, ValueError, IndexError):
|
||||
pass
|
||||
if parsed is None and "```" in cleaned:
|
||||
try:
|
||||
start = cleaned.index("```") + 3
|
||||
end = cleaned.index("```", start)
|
||||
parsed = json.loads(cleaned[start:end].strip())
|
||||
s = cleaned.index("```") + 3
|
||||
e = cleaned.index("```", s)
|
||||
parsed = json.loads(cleaned[s:e].strip())
|
||||
except (json.JSONDecodeError, ValueError, IndexError):
|
||||
pass
|
||||
|
||||
# Step 4: Brute-force — find the first valid JSON object in the text
|
||||
# 3. Brace-counting extraction
|
||||
if parsed is None:
|
||||
parsed = self._extract_json_object(cleaned)
|
||||
|
||||
# Step 5: If still nothing, try on the original content (in case
|
||||
# think-tag stripping was too aggressive)
|
||||
# 4. Fallback on original content
|
||||
if parsed is None and content != cleaned:
|
||||
parsed = self._extract_json_object(content)
|
||||
|
||||
if parsed is not None:
|
||||
logger.info("Successfully parsed JSON from LLM response")
|
||||
else:
|
||||
logger.warning("Could not extract JSON from LLM response (%d chars)",
|
||||
len(content))
|
||||
|
||||
logger.warning(
|
||||
"Could not extract JSON from LLM response (%d chars)", len(content)
|
||||
)
|
||||
return LLMResponse(
|
||||
content=content,
|
||||
parsed_json=parsed,
|
||||
@@ -192,94 +199,84 @@ class LLMClient:
|
||||
model=raw.get("model", self.model),
|
||||
)
|
||||
|
||||
def chat_sync(self, system_prompt: str, user_prompt: str,
|
||||
temperature: float = None, max_tokens: int = None,
|
||||
force_json: bool = True) -> LLMResponse:
|
||||
# ── Public API ─────────────────────────────────────────────
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
temperature: float = None,
|
||||
max_tokens: int = None,
|
||||
force_json: bool = True,
|
||||
) -> LLMResponse:
|
||||
"""
|
||||
Synchronous chat call to Ollama.
|
||||
Retries up to LLM_MAX_RETRIES on failure or non-JSON response.
|
||||
Async chat call to Ollama. Retries up to LLM_MAX_RETRIES.
|
||||
Never blocks the event loop — uses httpx.AsyncClient throughout.
|
||||
"""
|
||||
temp = temperature if temperature is not None else DEFAULT_TEMPERATURE
|
||||
tokens = max_tokens if max_tokens is not None else DEFAULT_MAX_TOKENS
|
||||
body = self._build_request_body(system_prompt, user_prompt,
|
||||
temp, tokens, force_json)
|
||||
|
||||
last_error = None
|
||||
body = await self._build_request_body(
|
||||
system_prompt, user_prompt, temp, tokens, force_json
|
||||
)
|
||||
last_error: Optional[Exception] = None
|
||||
for attempt in range(1, LLM_MAX_RETRIES + 1):
|
||||
try:
|
||||
logger.info("LLM call attempt %d/%d", attempt, LLM_MAX_RETRIES)
|
||||
req = Request(
|
||||
f"{self.base_url}/chat/completions",
|
||||
data=json.dumps(body).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urlopen(req, timeout=self.timeout) as resp:
|
||||
raw = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(float(self.timeout))
|
||||
) as client:
|
||||
resp = await client.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
json=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
raw = resp.json()
|
||||
result = self._parse_response(raw)
|
||||
|
||||
# If we need JSON and didn't get it, retry
|
||||
if force_json and result.parsed_json is None:
|
||||
logger.warning("Attempt %d: LLM returned non-JSON, retrying", attempt)
|
||||
logger.warning(
|
||||
"Attempt %d: LLM returned non-JSON, retrying", attempt
|
||||
)
|
||||
last_error = ValueError("Non-JSON response from LLM")
|
||||
continue
|
||||
|
||||
return result
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.warning("Attempt %d HTTP error %s", attempt, e.response.status_code)
|
||||
last_error = e
|
||||
# 400 with json format → retry without response_format constraint
|
||||
if e.response.status_code == 400 and force_json:
|
||||
logger.warning("Got 400 — retrying without response_format")
|
||||
force_json = False
|
||||
body = await self._build_request_body(
|
||||
system_prompt, user_prompt, temp, tokens, False
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Attempt %d failed: %s", attempt, e)
|
||||
last_error = e
|
||||
|
||||
# If 400 and we used json response_format, retry without it
|
||||
error_str = str(e)
|
||||
if "400" in error_str and force_json:
|
||||
logger.warning("Got 400 — retrying without response_format constraint")
|
||||
force_json = False
|
||||
body = self._build_request_body(
|
||||
system_prompt, user_prompt, temp, tokens, False)
|
||||
|
||||
if attempt < LLM_MAX_RETRIES:
|
||||
import time
|
||||
time.sleep(2 * attempt) # Simple backoff
|
||||
|
||||
if attempt < LLM_MAX_RETRIES:
|
||||
import asyncio
|
||||
await asyncio.sleep(2 * attempt)
|
||||
raise ConnectionError(
|
||||
f"LLM call failed after {LLM_MAX_RETRIES} attempts: {last_error}"
|
||||
)
|
||||
|
||||
async def chat(self, system_prompt: str, user_prompt: str,
|
||||
temperature: float = None, max_tokens: int = None,
|
||||
force_json: bool = True) -> LLMResponse:
|
||||
"""
|
||||
Async chat call — runs the sync call in a thread executor
|
||||
so it doesn't block the FastAPI event loop.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.chat_sync(system_prompt, user_prompt,
|
||||
temperature, max_tokens, force_json)
|
||||
)
|
||||
|
||||
def check_connection(self) -> dict:
|
||||
"""
|
||||
Check if Ollama is reachable. Returns status dict.
|
||||
"""
|
||||
async def check_connection(self) -> dict:
|
||||
"""Check if Ollama is reachable. Returns status dict."""
|
||||
try:
|
||||
req = Request(
|
||||
f"{self.base_url}/models",
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="GET",
|
||||
)
|
||||
with urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
models = data.get("data", [])
|
||||
model_ids = [m.get("id", "unknown") for m in models]
|
||||
return {
|
||||
"connected": True,
|
||||
"models": model_ids,
|
||||
"active_model": model_ids[0] if model_ids else self.model,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0)) as client:
|
||||
resp = await client.get(
|
||||
f"{self.base_url}/models",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
models = data.get("data", [])
|
||||
model_ids = [m.get("id", "unknown") for m in models]
|
||||
return {
|
||||
"connected": True,
|
||||
"models": model_ids,
|
||||
"active_model": model_ids[0] if model_ids else self.model,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"connected": False,
|
||||
|
||||
@@ -84,6 +84,13 @@ async def _on_startup():
|
||||
if not COMMANDER_META:
|
||||
load_commander_meta()
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def _on_shutdown():
|
||||
"""Close the async SQLite connection pool."""
|
||||
from services.async_db import async_close_db
|
||||
await async_close_db()
|
||||
|
||||
# ── Static UI ──────────────────────────────────────────────────
|
||||
_legacy_ui_dir = Path(__file__).parent / "ui"
|
||||
_spa_dir = Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist"
|
||||
|
||||
@@ -8,6 +8,7 @@ pydantic>=2.0.0
|
||||
httpx>=0.24.0
|
||||
websockets>=11.0
|
||||
openai>=1.0.0
|
||||
aiosqlite>=0.19.0
|
||||
|
||||
# Anthropic Claude API
|
||||
anthropic>=0.40.0
|
||||
|
||||
+421
-594
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,220 @@
|
||||
"""Async SQLite wrapper for FastAPI — eliminates event-loop blocking.
|
||||
|
||||
Drop-in async replacements for the sync helpers in services/database.py.
|
||||
Uses aiosqlite under the hood so every DB call yields back to the
|
||||
event loop instead of freezing it.
|
||||
|
||||
Usage in route handlers:
|
||||
from services.async_db import async_get_db, async_execute, async_fetchall
|
||||
|
||||
rows = await async_fetchall("SELECT * FROM decks")
|
||||
row = await async_fetchone("SELECT * FROM decks WHERE id = ?", (1,))
|
||||
await async_execute("INSERT INTO ...", params)
|
||||
await async_execute_commit("DELETE FROM ...", params)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import aiosqlite
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.async_db")
|
||||
|
||||
# Re-use the same DB path as the sync module
|
||||
COLLECTION_DB_PATH = Path(__file__).parent.parent / "collection.db"
|
||||
|
||||
# Module-level connection (lazy-init, one per process)
|
||||
_aconn: Optional[aiosqlite.Connection] = None
|
||||
|
||||
|
||||
async def async_get_db() -> aiosqlite.Connection:
|
||||
"""Return (or create) the module-level async SQLite connection."""
|
||||
global _aconn
|
||||
if _aconn is None:
|
||||
_aconn = await aiosqlite.connect(str(COLLECTION_DB_PATH))
|
||||
_aconn.row_factory = aiosqlite.Row
|
||||
await _aconn.execute("PRAGMA journal_mode=WAL")
|
||||
log.info("Async SQLite connection opened: %s", COLLECTION_DB_PATH)
|
||||
return _aconn
|
||||
|
||||
|
||||
async def async_close_db() -> None:
|
||||
"""Close the module-level connection (call on shutdown)."""
|
||||
global _aconn
|
||||
if _aconn is not None:
|
||||
await _aconn.close()
|
||||
_aconn = None
|
||||
log.info("Async SQLite connection closed.")
|
||||
|
||||
|
||||
# ── Convenience helpers ─────────────────────────────────────────
|
||||
|
||||
async def async_fetchall(
|
||||
sql: str, params: tuple = (), *, conn: aiosqlite.Connection = None
|
||||
) -> list[dict]:
|
||||
"""Execute a SELECT and return all rows as dicts."""
|
||||
db = conn or await async_get_db()
|
||||
async with db.execute(sql, params) as cursor:
|
||||
columns = [d[0] for d in cursor.description]
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(zip(columns, row)) for row in rows]
|
||||
|
||||
|
||||
async def async_fetchone(
|
||||
sql: str, params: tuple = (), *, conn: aiosqlite.Connection = None
|
||||
) -> Optional[dict]:
|
||||
"""Execute a SELECT and return one row as a dict (or None)."""
|
||||
db = conn or await async_get_db()
|
||||
async with db.execute(sql, params) as cursor:
|
||||
columns = [d[0] for d in cursor.description]
|
||||
row = await cursor.fetchone()
|
||||
return dict(zip(columns, row)) if row else None
|
||||
|
||||
|
||||
async def async_execute(
|
||||
sql: str, params: tuple = (), *, conn: aiosqlite.Connection = None
|
||||
) -> int:
|
||||
"""Execute a write statement. Returns lastrowid."""
|
||||
db = conn or await async_get_db()
|
||||
cursor = await db.execute(sql, params)
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
async def async_execute_commit(
|
||||
sql: str, params: tuple = (), *, conn: aiosqlite.Connection = None
|
||||
) -> int:
|
||||
"""Execute a write statement and commit. Returns lastrowid."""
|
||||
db = conn or await async_get_db()
|
||||
cursor = await db.execute(sql, params)
|
||||
await db.commit()
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
async def async_executemany_commit(
|
||||
sql: str, params_seq, *, conn: aiosqlite.Connection = None
|
||||
) -> None:
|
||||
"""Execute many and commit."""
|
||||
db = conn or await async_get_db()
|
||||
await db.executemany(sql, params_seq)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def async_commit(*, conn: aiosqlite.Connection = None) -> None:
|
||||
"""Commit the current transaction."""
|
||||
db = conn or await async_get_db()
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── JSON field helpers (mirrors database._row_to_dict) ──────────
|
||||
|
||||
_JSON_FIELDS = ("category", "color_identity", "subtypes", "keywords")
|
||||
|
||||
|
||||
def row_to_dict_parsed(row: dict) -> dict:
|
||||
"""Parse JSON string columns into Python lists (same as database._row_to_dict)."""
|
||||
if row is None:
|
||||
return None
|
||||
d = dict(row)
|
||||
for f in _JSON_FIELDS:
|
||||
if f in d and isinstance(d[f], str):
|
||||
try:
|
||||
d[f] = json.loads(d[f])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
d[f] = []
|
||||
return d
|
||||
|
||||
|
||||
# ── Coach session async helpers ─────────────────────────────────
|
||||
|
||||
async def async_save_coach_session(session_data: dict) -> int:
|
||||
"""Insert or update a coach session. Returns row id."""
|
||||
db = await async_get_db()
|
||||
existing = await async_fetchone(
|
||||
"SELECT id FROM coach_sessions WHERE session_id = ?",
|
||||
(session_data["session_id"],),
|
||||
)
|
||||
if existing:
|
||||
await db.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"],
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
return existing["id"]
|
||||
else:
|
||||
cursor = await db.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", ""),
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
async def async_load_coach_session(session_id: str) -> Optional[dict]:
|
||||
"""Load a single coach session by session_id."""
|
||||
return await async_fetchone(
|
||||
"SELECT * FROM coach_sessions WHERE session_id = ?", (session_id,)
|
||||
)
|
||||
|
||||
|
||||
async def async_list_coach_sessions(deck_id: str = None) -> list[dict]:
|
||||
"""List coach sessions, optionally filtered by deck_id."""
|
||||
if deck_id:
|
||||
return await async_fetchall(
|
||||
"SELECT session_id, deck_id, timestamp, summary, created_at "
|
||||
"FROM coach_sessions WHERE deck_id = ? ORDER BY timestamp DESC",
|
||||
(deck_id,),
|
||||
)
|
||||
return await async_fetchall(
|
||||
"SELECT session_id, deck_id, timestamp, summary, created_at "
|
||||
"FROM coach_sessions ORDER BY timestamp DESC"
|
||||
)
|
||||
|
||||
|
||||
async def async_delete_coach_session(session_id: str) -> bool:
|
||||
"""Delete a coach session. Returns True if a row was deleted."""
|
||||
db = await async_get_db()
|
||||
cursor = await db.execute(
|
||||
"DELETE FROM coach_sessions WHERE session_id = ?", (session_id,)
|
||||
)
|
||||
await db.commit()
|
||||
return cursor.rowcount > 0
|
||||
Reference in new issue
Block a user