Merge remote-tracking branch 'origin/main' into prep/pr-659

# Conflicts:
#	backend/database/models.py
This commit is contained in:
jamiepine
2026-10-04 00:02:23 +00:00
49 changed files with 811 additions and 123 deletions
+51 -1
View File
@@ -44,6 +44,7 @@ def run_migrations(engine) -> None:
_migrate_capture_settings(engine, inspector, tables)
_migrate_mcp_bindings(engine, inspector, tables)
_normalize_storage_paths(engine, tables)
_migrate_add_indexes(engine, tables)
# -- helpers ---------------------------------------------------------------
@@ -249,7 +250,7 @@ def _migrate_mcp_bindings(engine, inspector, tables: set[str]) -> None:
"""Drop the legacy ``default_intent`` column and add ``default_personality``.
The intent tri-state (respond / rewrite / compose) has been collapsed
to a boolean: when true, ``voicebox.speak`` rewrites input through the
to a boolean: when true, ``voicebox_speak`` rewrites input through the
profile's personality LLM before TTS.
"""
if "mcp_client_bindings" not in tables:
@@ -292,6 +293,55 @@ def _supports_drop_column(engine) -> bool:
return tuple(int(p) for p in sqlite3.sqlite_version.split(".")[:3]) >= (3, 35, 0)
def _migrate_add_indexes(engine, tables: set[str]) -> None:
"""Create missing indexes on high-traffic foreign keys and sort columns.
SQLite silently ignores ``CREATE INDEX IF NOT EXISTS``, so this is
safe to run on every startup regardless of whether the index already
exists. New installs get the indexes from ``Base.metadata.create_all``
(via the ``index=True`` column flags); this migration brings existing
databases into parity without dropping or recreating any data.
"""
indexes = [
# generations — filtered by profile, ordered/filtered by date, filtered by status
("ix_generations_profile_id", "generations", "profile_id"),
("ix_generations_created_at", "generations", "created_at"),
("ix_generations_status", "generations", "status"),
# story_items — every story lookup filters by story_id; join on generation_id
("ix_story_items_story_id", "story_items", "story_id"),
("ix_story_items_generation_id", "story_items", "generation_id"),
# generation_versions — always filtered/joined on generation_id
("ix_generation_versions_generation_id", "generation_versions", "generation_id"),
# profile_samples — loaded per-profile on every voice prompt build
("ix_profile_samples_profile_id", "profile_samples", "profile_id"),
# captures — ordered by date in list view
("ix_captures_created_at", "captures", "created_at"),
# channel_device_mappings — looked up per channel
("ix_channel_device_mappings_channel_id", "channel_device_mappings", "channel_id"),
]
with engine.connect() as conn:
existing = {
row[0]
for row in conn.execute(text("SELECT name FROM sqlite_master WHERE type = 'index'"))
}
created = []
for index_name, table, column in indexes:
if table not in tables or index_name in existing:
continue
conn.execute(
text(
f"CREATE INDEX IF NOT EXISTS {index_name}"
f" ON {table} ({column})"
)
)
created.append(index_name)
conn.commit()
if created:
logger.info("Created %d missing index(es): %s", len(created), ", ".join(created))
def _normalize_storage_paths(engine, tables: set[str]) -> None:
"""Normalize stored file paths to be relative to the configured data dir."""
from pathlib import Path
+10 -10
View File
@@ -54,7 +54,7 @@ class ProfileSample(Base):
__tablename__ = "profile_samples"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
profile_id = Column(String, ForeignKey("profiles.id"), nullable=False)
profile_id = Column(String, ForeignKey("profiles.id"), nullable=False, index=True)
audio_path = Column(String, nullable=False)
reference_text = Column(Text, nullable=False)
@@ -65,7 +65,7 @@ class Generation(Base):
__tablename__ = "generations"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
profile_id = Column(String, ForeignKey("profiles.id"), nullable=False)
profile_id = Column(String, ForeignKey("profiles.id"), nullable=False, index=True)
text = Column(Text, nullable=False)
language = Column(String, default="en")
audio_path = Column(String, nullable=True)
@@ -74,7 +74,7 @@ class Generation(Base):
instruct = Column(Text)
engine = Column(String, default="qwen")
model_size = Column(String, nullable=True)
status = Column(String, default="completed")
status = Column(String, default="completed", index=True)
error = Column(Text, nullable=True)
is_favorited = Column(Boolean, default=False)
# Origin of this generation — "manual" for plain /generate calls,
@@ -82,7 +82,7 @@ class Generation(Base):
# profile's personality LLM before TTS. Future sources (bulk import,
# agent replies, etc.) can extend this.
source = Column(String, nullable=False, default="manual")
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
created_at = Column(DateTime, default=lambda: datetime.now(UTC), index=True)
class Story(Base):
@@ -103,8 +103,8 @@ class StoryItem(Base):
__tablename__ = "story_items"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
story_id = Column(String, ForeignKey("stories.id"), nullable=False)
generation_id = Column(String, ForeignKey("generations.id"), nullable=False)
story_id = Column(String, ForeignKey("stories.id"), nullable=False, index=True)
generation_id = Column(String, ForeignKey("generations.id"), nullable=False, index=True)
version_id = Column(String, ForeignKey("generation_versions.id"), nullable=True)
start_time_ms = Column(Integer, nullable=False, default=0)
track = Column(Integer, nullable=False, default=0)
@@ -132,7 +132,7 @@ class GenerationVersion(Base):
__tablename__ = "generation_versions"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
generation_id = Column(String, ForeignKey("generations.id"), nullable=False)
generation_id = Column(String, ForeignKey("generations.id"), nullable=False, index=True)
label = Column(String, nullable=False)
audio_path = Column(String, nullable=False)
effects_chain = Column(Text, nullable=True)
@@ -172,7 +172,7 @@ class ChannelDeviceMapping(Base):
__tablename__ = "channel_device_mappings"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
channel_id = Column(String, ForeignKey("audio_channels.id"), nullable=False)
channel_id = Column(String, ForeignKey("audio_channels.id"), nullable=False, index=True)
device_id = Column(String, nullable=False)
@@ -272,7 +272,7 @@ class MCPClientBinding(Base):
label = Column(String, nullable=True) # display name
profile_id = Column(String, ForeignKey("profiles.id"), nullable=True)
default_engine = Column(String, nullable=True)
# When true, voicebox.speak routes through the profile's personality LLM
# When true, voicebox_speak routes through the profile's personality LLM
# (rewrite) before TTS by default. Callers can still override per call.
default_personality = Column(Boolean, nullable=False, default=False)
last_seen_at = Column(DateTime, nullable=True)
@@ -300,4 +300,4 @@ class Capture(Base):
stt_model = Column(String, nullable=True)
llm_model = Column(String, nullable=True)
refinement_flags = Column(Text, nullable=True) # JSON blob
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
created_at = Column(DateTime, default=lambda: datetime.now(UTC), index=True)
+16 -1
View File
@@ -3,7 +3,7 @@
import logging
import uuid
from sqlalchemy import create_engine
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from .. import config
@@ -21,6 +21,7 @@ from .seed import backfill_generation_versions, seed_builtin_presets
logger = logging.getLogger(__name__)
# Initialized by init_db()
engine = None
SessionLocal = None
@@ -39,6 +40,20 @@ def init_db() -> None:
connect_args={"check_same_thread": False},
)
@event.listens_for(engine, "connect")
def _set_sqlite_pragmas(dbapi_connection, _record) -> None:
# Each pooled connection enables WAL journal mode and sets a 5-second
# busy timeout. WAL allows concurrent readers during a write (the
# default DELETE/ROLLBACK journal blocks all readers), which matters
# for voicebox because SSE status polls and history queries run
# concurrently with the generation worker writing to the same db.
# busy_timeout prevents "database is locked" errors when two
# connections briefly contend on the same write slot.
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.close()
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
run_migrations(engine)