Skip to content

Commit 77b4037

Browse files
committed
fix(speaker): create a tenant on first use instead of seeding one at startup
Making users.id a Chronicle id broke the cold-start path, which CI caught and a warm local database cannot: startup called ensure_admin_user -> User(username="admin") with no id. That relied on integer autoincrement, so it now inserts a NULL primary key, the service dies before serving /health, and the integration job waits 600s for a container that will never come up. A tenant is a Chronicle user id, so the service cannot invent one before a caller names it. Nothing is seeded; the row is created on that user's first enrolment, and get_or_create_user is keyed on the id rather than the username. Three more places still assumed the integer: - extract_user_id_from_speaker_id parsed int(speaker_id.split("_")[1]). It now reads speakers.user_id, which is the actual ownership record — a parse of the id convention is not, and it stops being true for an imported or differently-formatted speaker. This is the "do not infer the tenant from a SpeakerId" case from the audit. - The websocket enhancement path ran int() over the tenant and fell back to None on failure, so every ObjectId silently became "no user" — which reads downstream as "search every gallery", the opposite of what it must mean. - Six log lines formatted the tenant with %d, and the FAISS position map was typed Dict[int, Tuple[int, str]]. Cold-database regression tests added, since no warm database exercises this.
1 parent e0355ec commit 77b4037

14 files changed

Lines changed: 128 additions & 53 deletions

File tree

extras/speaker-recognition/src/simple_speaker_recognition/api/core/utils.py

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -105,24 +105,30 @@ def secure_temp_file(suffix: str = ".wav") -> tempfile._TemporaryFileWrapper:
105105
return tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
106106

107107

108-
def extract_user_id_from_speaker_id(speaker_id: str) -> int:
109-
"""Extract user_id from speaker_id format: user_{user_id}_..."""
110-
if not speaker_id.startswith("user_"):
111-
raise HTTPException(
112-
400,
113-
f"Invalid speaker_id format. Expected 'user_{{user_id}}_...', got: {speaker_id}",
114-
)
108+
def extract_user_id_from_speaker_id(speaker_id: str) -> str:
109+
"""Return the tenant that owns ``speaker_id``, read from the speaker row.
110+
111+
Deliberately a lookup rather than a parse. The id is *conventionally*
112+
``user_{user_id}_speaker_{hex}``, but that convention is not the ownership
113+
record — the ``speakers.user_id`` column is, and it stays right when a speaker is
114+
imported, renamed, or created by a client that formats ids differently. The old
115+
``int(speaker_id.split("_")[1])`` also stopped working the moment a tenant became
116+
a Chronicle ObjectId, which is not an integer.
117+
"""
115118

119+
# Imported here to avoid a circular import: database.queries imports the API
120+
# models that this utils module is itself imported by.
121+
from simple_speaker_recognition.database import get_db_session
122+
from simple_speaker_recognition.database.models import Speaker
123+
124+
db = get_db_session()
116125
try:
117-
parts = speaker_id.split("_")
118-
if len(parts) < 2:
119-
raise ValueError("Not enough parts")
120-
user_id = int(parts[1])
121-
return user_id
122-
except (ValueError, IndexError):
123-
raise HTTPException(
124-
400, f"Invalid speaker_id format. Cannot extract user_id from: {speaker_id}"
125-
)
126+
speaker = db.query(Speaker).filter(Speaker.id == speaker_id).first()
127+
if speaker is None:
128+
raise HTTPException(404, f"Speaker not found: {speaker_id}")
129+
return str(speaker.user_id)
130+
finally:
131+
db.close()
126132

127133

128134
def validate_confidence(confidence: Any, context: str = "") -> float:

extras/speaker-recognition/src/simple_speaker_recognition/api/routers/deepgram_wrapper.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
Request,
2020
UploadFile,
2121
)
22+
2223
from simple_speaker_recognition.api.core.utils import (
2324
safe_format_confidence,
2425
validate_confidence,

extras/speaker-recognition/src/simple_speaker_recognition/api/routers/enrollment.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
import numpy as np
1212
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
13+
from sqlalchemy import func
14+
1315
from simple_speaker_recognition.api.core.utils import (
1416
extract_user_id_from_speaker_id,
1517
get_data_directory,
@@ -19,7 +21,6 @@
1921
from simple_speaker_recognition.database import get_db_session
2022
from simple_speaker_recognition.database.models import Speaker, SpeakerAudioSegment
2123
from simple_speaker_recognition.utils.audio_processing import get_audio_info
22-
from sqlalchemy import func
2324

2425
# These will be imported from the main service.py when we integrate
2526
# from ..service import get_db, audio_backend, auth

extras/speaker-recognition/src/simple_speaker_recognition/api/routers/enrollment_audit.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
2020
from fastapi.responses import FileResponse
2121
from pydantic import BaseModel, Field
22+
2223
from simple_speaker_recognition.api.core.utils import secure_temp_file
2324
from simple_speaker_recognition.core.enrollment_audit import (
2425
compute_audit,

extras/speaker-recognition/src/simple_speaker_recognition/api/routers/identification.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import numpy as np
1414
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
1515
from pydantic import BaseModel
16+
1617
from simple_speaker_recognition.api.core.utils import (
1718
safe_format_confidence,
1819
secure_temp_file,

extras/speaker-recognition/src/simple_speaker_recognition/api/routers/speakers.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import numpy as np
1111
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
1212
from fastapi.responses import FileResponse
13+
1314
from simple_speaker_recognition.api.core.utils import extract_user_id_from_speaker_id
1415
from simple_speaker_recognition.constants import DEFAULT_SIMILARITY_THRESHOLD
1516
from simple_speaker_recognition.core.unified_speaker_db import UnifiedSpeakerDB

extras/speaker-recognition/src/simple_speaker_recognition/api/routers/users.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""User management endpoints."""
22

33
from fastapi import APIRouter
4+
45
from simple_speaker_recognition.core.models import UserRequest, UserResponse
56
from simple_speaker_recognition.database import get_db_session
67
from simple_speaker_recognition.database.queries import UserQueries
@@ -31,7 +32,7 @@ async def create_user(request: UserRequest):
3132
"""Create or get existing user."""
3233
db = get_db_session()
3334
try:
34-
user = UserQueries.get_or_create_user(db, request.username)
35+
user = UserQueries.get_or_create_user(db, request.user_id, request.username)
3536
return UserResponse(
3637
id=user.id, username=user.username, created_at=user.created_at.isoformat()
3738
)

extras/speaker-recognition/src/simple_speaker_recognition/api/routers/websocket_wrapper.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
)
2525
from pyannote.audio import Model
2626
from pyannote.audio.pipelines import VoiceActivityDetection
27+
2728
from simple_speaker_recognition.api.core.utils import (
2829
safe_format_confidence,
2930
validate_confidence,
@@ -804,14 +805,10 @@ async def deepgram_proxy_websocket(
804805
except (ValueError, TypeError):
805806
confidence_threshold = DEFAULT_SIMILARITY_THRESHOLD
806807

807-
# Parse user_id
808-
if enhancement_params["user_id"]:
809-
try:
810-
user_id = int(enhancement_params["user_id"])
811-
except (ValueError, TypeError):
812-
user_id = None
813-
else:
814-
user_id = None
808+
# The tenant is an opaque Chronicle id, so it is taken as given rather than
809+
# coerced. int() here silently turned every ObjectId into "no user", which reads
810+
# downstream as "search every gallery".
811+
user_id = enhancement_params["user_id"] or None
815812

816813
log.info(
817814
f"Enhancement params - user_id: {user_id}, confidence_threshold: {confidence_threshold}"

extras/speaker-recognition/src/simple_speaker_recognition/api/service.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from omegaconf import OmegaConf
1717
from pydantic import Field
1818
from pydantic_settings import BaseSettings
19+
1920
from simple_speaker_recognition.api.core.utils import get_data_directory
2021
from simple_speaker_recognition.constants import DEFAULT_SIMILARITY_THRESHOLD
2122
from simple_speaker_recognition.core.audio_backend import AudioBackend
@@ -105,7 +106,7 @@ class Settings(BaseSettings):
105106
# Backend API configuration for chunked processing
106107
# Loaded from root config.yml speaker_recognition section, can be overridden by env vars
107108
max_diarize_duration: int = Field(
108-
default=60,
109+
default=1200,
109110
description="Maximum audio duration (seconds) for single PyAnnote call",
110111
)
111112
diarize_chunk_overlap: float = Field(
@@ -132,7 +133,9 @@ def __init__(self, **kwargs):
132133
"max_diarize_duration" not in kwargs
133134
and "MAX_DIARIZE_DURATION" not in os.environ
134135
):
135-
kwargs["max_diarize_duration"] = root_config.get("max_diarize_duration", 60)
136+
kwargs["max_diarize_duration"] = root_config.get(
137+
"max_diarize_duration", 1200
138+
)
136139

137140
if (
138141
"diarize_chunk_overlap" not in kwargs
@@ -278,9 +281,8 @@ async def lifespan(app: FastAPI):
278281
auth.enrollment_audio_dir.mkdir(parents=True, exist_ok=True)
279282
log.info("Enrollment audio directory ready: %s", auth.enrollment_audio_dir)
280283

281-
# Ensure admin user exists
282-
admin_user_id = speaker_db.ensure_admin_user()
283-
log.info("Admin user ready ✔ – user_id=%s", admin_user_id)
284+
# No tenant is seeded here. A tenant is a Chronicle user id, which only a caller
285+
# can supply, so rows are created on first enrolment instead.
284286

285287
# Start the CUDA self-heal watchdog (no-op on CPU).
286288
watchdog_task = asyncio.create_task(_cuda_watchdog())

extras/speaker-recognition/src/simple_speaker_recognition/core/enrollment_audit.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from typing import Optional
2424

2525
import numpy as np
26+
2627
from simple_speaker_recognition.database.models import (
2728
EnrollmentAuditDecision,
2829
Speaker,

0 commit comments

Comments
 (0)