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 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user