Files
voicebox/backend/services/settings.py
T
Jamie Pine 800e390108 fix(settings): honor explicit null on nullable fields, ignore on the rest
Routes were calling model_dump(exclude_none=True), which drops every
client-sent null before it reaches the service. The service then layered
on its own `if value is not None` guard. Net effect: setting a nullable
column back to null was a no-op — the MCPPage default-voice picker sends
null when the user picks "no default" and the row was silently keeping
whatever was there before.

Switched the routes to exclude_unset=True so absent fields stay absent
but explicit nulls survive the dump, and centralised the per-field
nullability check in the service. The check inspects the SQLAlchemy
column metadata so non-nullable columns (stt_model, llm_model, the chord
key lists) still drop nulls instead of crashing the request, while
default_playback_voice_id can finally be cleared.
2026-04-24 20:36:48 -07:00

83 lines
2.6 KiB
Python

"""
Server-side user settings — singleton rows persisted in SQLite so every
client window, API consumer, and headless flow reads the same preferences.
Two domains live here: capture/refine defaults and long-form generation
defaults. Each has a ``get_*`` that lazily creates the row with defaults and
an ``update_*`` that accepts a partial payload.
"""
from typing import Any
from sqlalchemy.orm import Session
from ..database import CaptureSettings as DBCaptureSettings
from ..database import GenerationSettings as DBGenerationSettings
SINGLETON_ID = 1
def _get_or_create_capture_row(db: Session) -> DBCaptureSettings:
row = db.query(DBCaptureSettings).filter(DBCaptureSettings.id == SINGLETON_ID).first()
if row is None:
row = DBCaptureSettings(id=SINGLETON_ID)
db.add(row)
db.commit()
db.refresh(row)
return row
def _get_or_create_generation_row(db: Session) -> DBGenerationSettings:
row = db.query(DBGenerationSettings).filter(DBGenerationSettings.id == SINGLETON_ID).first()
if row is None:
row = DBGenerationSettings(id=SINGLETON_ID)
db.add(row)
db.commit()
db.refresh(row)
return row
def _apply_patch(row: Any, patch: dict[str, Any]) -> None:
"""Apply a partial update to a settings row.
Values explicitly set to ``None`` are honored only for columns where the
schema allows it — clearing ``default_playback_voice_id`` works, but a
``None`` for a non-nullable field is dropped rather than crashing the
request. Unknown keys are ignored.
"""
columns = type(row).__table__.columns
for key, value in patch.items():
col = columns.get(key)
if col is None:
continue
if value is None and not col.nullable:
continue
setattr(row, key, value)
def get_capture_settings(db: Session) -> DBCaptureSettings:
"""Return the capture settings row, creating it with defaults if missing."""
return _get_or_create_capture_row(db)
def update_capture_settings(db: Session, patch: dict[str, Any]) -> DBCaptureSettings:
row = _get_or_create_capture_row(db)
_apply_patch(row, patch)
db.commit()
db.refresh(row)
return row
def get_generation_settings(db: Session) -> DBGenerationSettings:
"""Return the generation settings row, creating it with defaults if missing."""
return _get_or_create_generation_row(db)
def update_generation_settings(db: Session, patch: dict[str, Any]) -> DBGenerationSettings:
row = _get_or_create_generation_row(db)
_apply_patch(row, patch)
db.commit()
db.refresh(row)
return row