"""SQLite persistence for interaction telemetry (Session 4). Every ``/ask`` logs a row here with its measured latency; ``/feedback`` updates that row's thumbs rating; ``/stats`` aggregates the table for the dashboard. Uses the stdlib ``sqlite3`` with a single shared connection guarded by a lock — FastAPI runs sync endpoints in a threadpool, so ``check_same_thread=False`` plus the lock keeps concurrent writes safe. The DB path comes from ``DOCUASK_DB`` (default ``docuask.db``); tests point it at ``:memory:``. """ from __future__ import annotations import os import sqlite3 import statistics import threading import uuid from datetime import datetime, timezone _DEFAULT_PATH = os.getenv("DOCUASK_DB", "docuask.db") _lock = threading.Lock() _conn: sqlite3.Connection | None = None _SCHEMA = """ CREATE TABLE IF NOT EXISTS interactions ( id TEXT PRIMARY KEY, document_id TEXT NOT NULL, question TEXT NOT NULL, answer TEXT NOT NULL, latency_ms INTEGER NOT NULL, feedback TEXT, -- NULL | 'up' | 'down' created_at TEXT NOT NULL -- ISO-8601 UTC ); """ def _connect(path: str) -> sqlite3.Connection: conn = sqlite3.connect(path, check_same_thread=False) conn.row_factory = sqlite3.Row conn.executescript(_SCHEMA) conn.commit() return conn def get_conn() -> sqlite3.Connection: global _conn if _conn is None: _conn = _connect(_DEFAULT_PATH) return _conn def configure(path: str) -> sqlite3.Connection: """(Re)point the store at ``path``. Used by tests to isolate each case.""" global _conn with _lock: if _conn is not None: _conn.close() _conn = _connect(path) return _conn def log_interaction(document_id: str, question: str, answer: str, latency_ms: int) -> str: """Persist one Q&A turn and return its generated interaction id.""" interaction_id = uuid.uuid4().hex[:12] created_at = datetime.now(timezone.utc).isoformat() with _lock: conn = get_conn() conn.execute( "INSERT INTO interactions " "(id, document_id, question, answer, latency_ms, feedback, created_at) " "VALUES (?, ?, ?, ?, ?, NULL, ?)", (interaction_id, document_id, question, answer, latency_ms, created_at), ) conn.commit() return interaction_id def set_feedback(interaction_id: str, feedback: str) -> bool: """Set the thumbs rating for a row. Returns False if the id is unknown.""" with _lock: conn = get_conn() cur = conn.execute( "UPDATE interactions SET feedback = ? WHERE id = ?", (feedback, interaction_id), ) conn.commit() return cur.rowcount > 0 def get_stats() -> dict[str, object]: """Aggregate metrics for the dashboard.""" with _lock: conn = get_conn() total = conn.execute("SELECT COUNT(*) AS c FROM interactions").fetchone()["c"] latencies = [ row["latency_ms"] for row in conn.execute("SELECT latency_ms FROM interactions").fetchall() ] up = conn.execute( "SELECT COUNT(*) AS c FROM interactions WHERE feedback = 'up'" ).fetchone()["c"] down = conn.execute( "SELECT COUNT(*) AS c FROM interactions WHERE feedback = 'down'" ).fetchone()["c"] by_day = conn.execute( "SELECT substr(created_at, 1, 10) AS day, COUNT(*) AS c " "FROM interactions GROUP BY day ORDER BY day" ).fetchall() rated = up + down return { "total_questions": total, "median_latency_ms": round(statistics.median(latencies)) if latencies else None, "thumbs_up_rate": (up / rated) if rated else None, "thumbs_up": up, "thumbs_down": down, "questions_over_time": [{"date": r["day"], "count": r["c"]} for r in by_day], }