From 052f0190750b3bc6f3a73e8f360d2f42601b7eea Mon Sep 17 00:00:00 2001 From: Pluto Date: Thu, 28 May 2026 10:28:05 -0500 Subject: [PATCH] feat: add database schema with WAL mode and FTS5 triggers SQLite database layer with thread-local connections, WAL journal mode, foreign key enforcement, and FTS5 full-text search on utterances via content-table triggers. TDD: 5 tests written first, all passing. Co-Authored-By: Claude Sonnet 4.6 --- app/shared/database.py | 88 ++++++++++++++++++++++++++++++++++++++++++ tests/conftest.py | 39 +++++++++++++++++++ tests/test_database.py | 41 ++++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 app/shared/database.py create mode 100644 tests/conftest.py create mode 100644 tests/test_database.py diff --git a/app/shared/database.py b/app/shared/database.py new file mode 100644 index 0000000..a9a3fed --- /dev/null +++ b/app/shared/database.py @@ -0,0 +1,88 @@ +import sqlite3 +import threading +from pathlib import Path +from app.shared.config import Settings + +_local = threading.local() + + +def get_db(settings: Settings) -> sqlite3.Connection: + if not hasattr(_local, "conn") or _local.conn is None: + conn = sqlite3.connect(settings.db_path, check_same_thread=False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + _local.conn = conn + return _local.conn + + +def init_schema(settings: Settings) -> None: + Path(settings.clips_dir).mkdir(parents=True, exist_ok=True) + db = get_db(settings) + db.executescript(""" + CREATE TABLE IF NOT EXISTS rooms ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + device_label TEXT, + is_active BOOLEAN DEFAULT 0, + last_seen DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS speakers ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS voice_embeddings ( + id INTEGER PRIMARY KEY, + speaker_id INTEGER NOT NULL REFERENCES speakers(id) ON DELETE CASCADE, + embedding BLOB NOT NULL, + audio_sample BLOB, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS utterances ( + id INTEGER PRIMARY KEY, + room_id INTEGER NOT NULL REFERENCES rooms(id), + speaker_id INTEGER REFERENCES speakers(id), + transcript TEXT NOT NULL, + embedding BLOB, + match_status TEXT NOT NULL DEFAULT 'unreviewed', + match_confidence REAL, + start_time DATETIME NOT NULL, + end_time DATETIME NOT NULL, + audio_clip_path TEXT, + livekit_room TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE VIRTUAL TABLE IF NOT EXISTS utterances_fts USING fts5( + transcript, content=utterances, content_rowid=id + ); + + CREATE TRIGGER IF NOT EXISTS utterances_ai AFTER INSERT ON utterances BEGIN + INSERT INTO utterances_fts(rowid, transcript) VALUES (new.id, new.transcript); + END; + + CREATE TRIGGER IF NOT EXISTS utterances_au AFTER UPDATE OF transcript ON utterances BEGIN + INSERT INTO utterances_fts(utterances_fts, rowid, transcript) + VALUES ('delete', old.id, old.transcript); + INSERT INTO utterances_fts(rowid, transcript) VALUES (new.id, new.transcript); + END; + + CREATE TRIGGER IF NOT EXISTS utterances_ad AFTER DELETE ON utterances BEGIN + INSERT INTO utterances_fts(utterances_fts, rowid, transcript) + VALUES ('delete', old.id, old.transcript); + END; + + CREATE TABLE IF NOT EXISTS daily_summaries ( + id INTEGER PRIMARY KEY, + date DATE NOT NULL UNIQUE, + summary_text TEXT NOT NULL, + generated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + """) + db.commit() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ca438c3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,39 @@ +import pytest +import os +from app.shared.config import Settings +from app.shared.database import init_schema, get_db + + +@pytest.fixture +def settings(tmp_path): + db_file = str(tmp_path / "test.db") + clips_dir = str(tmp_path / "clips") + os.makedirs(clips_dir, exist_ok=True) + s = Settings( + db_path=db_file, + clips_dir=clips_dir, + speaches_url="http://localhost:9999", + ollama_url="http://localhost:9999", + livekit_api_key="test", + livekit_api_secret="test", + ) + init_schema(s) + return s + + +@pytest.fixture +def db(settings): + return get_db(settings) + + +# Add to conftest.py +from app.shared import database as _db_module + +@pytest.fixture(autouse=True) +def reset_db_connection(): + """Reset thread-local DB connection between tests.""" + _db_module._local.conn = None + yield + if hasattr(_db_module._local, 'conn') and _db_module._local.conn: + _db_module._local.conn.close() + _db_module._local.conn = None diff --git a/tests/test_database.py b/tests/test_database.py new file mode 100644 index 0000000..3388eee --- /dev/null +++ b/tests/test_database.py @@ -0,0 +1,41 @@ +def test_schema_creates_rooms_table(db): + rows = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='rooms'").fetchall() + assert len(rows) == 1 + + +def test_schema_creates_utterances_fts(db): + rows = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='utterances_fts'").fetchall() + assert len(rows) == 1 + + +def test_insert_and_fetch_room(db): + db.execute("INSERT INTO rooms (name) VALUES (?)", ("Kitchen",)) + db.commit() + row = db.execute("SELECT name FROM rooms WHERE name='Kitchen'").fetchone() + assert row["name"] == "Kitchen" + + +def test_insert_speaker_and_embedding(db): + db.execute("INSERT INTO speakers (name) VALUES (?)", ("Jeremy",)) + db.commit() + speaker = db.execute("SELECT id FROM speakers WHERE name='Jeremy'").fetchone() + db.execute( + "INSERT INTO voice_embeddings (speaker_id, embedding) VALUES (?, ?)", + (speaker["id"], b"\x00\x01\x02\x03"), + ) + db.commit() + emb = db.execute("SELECT speaker_id FROM voice_embeddings").fetchone() + assert emb["speaker_id"] == speaker["id"] + + +def test_fts_trigger_on_utterance_insert(db): + db.execute("INSERT INTO rooms (name) VALUES (?)", ("Living Room",)) + db.commit() + room = db.execute("SELECT id FROM rooms WHERE name='Living Room'").fetchone() + db.execute( + "INSERT INTO utterances (room_id, transcript, match_status, start_time, end_time) VALUES (?,?,?,?,?)", + (room["id"], "hello world test", "unreviewed", "2026-01-01 10:00:00", "2026-01-01 10:00:03"), + ) + db.commit() + results = db.execute("SELECT rowid FROM utterances_fts WHERE transcript MATCH 'hello'").fetchall() + assert len(results) == 1