feat: utterances API routes — list, clip, tag, dismiss

Adds app/api/main.py (create_app factory with health endpoint), utterances
router (GET /api/utterances with date/room/speaker/status filters, GET clip,
POST tag, POST dismiss), stub routers for speakers/rooms/search/rag, and
tests/routes/ with 6 passing TDD tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 10:43:22 -05:00
parent 9a672c4c17
commit 4def6a526c
8 changed files with 273 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
import sqlite3
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from app.shared.config import Settings, get_settings
from app.shared.database import get_db, init_schema
from app.api.routes import utterances, speakers, rooms, search, rag
def create_app(settings: Settings) -> FastAPI:
init_schema(settings)
app = FastAPI(title="LinkedStorm")
def db_factory() -> sqlite3.Connection:
return get_db(settings)
app.include_router(utterances.make_router(settings, db_factory))
app.include_router(speakers.make_router(settings, db_factory))
app.include_router(rooms.make_router(settings, db_factory))
app.include_router(search.make_router(settings, db_factory))
app.include_router(rag.make_router(settings, db_factory))
@app.get("/health")
async def health():
return {"status": "ok"}
# Mount static files last so they don't shadow API routes.
# Only mount if the directory exists AND contains at least one file,
# otherwise StaticFiles raises an error on empty directories.
static_dir = Path(__file__).parent / "static"
if static_dir.exists() and any(static_dir.iterdir()):
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
return app
_app_instance = None
def app_factory():
global _app_instance
if _app_instance is None:
_app_instance = create_app(get_settings())
return _app_instance
+6
View File
@@ -0,0 +1,6 @@
from fastapi import APIRouter
def make_router(settings, db_factory):
router = APIRouter(prefix="/api")
return router
+6
View File
@@ -0,0 +1,6 @@
from fastapi import APIRouter
def make_router(settings, db_factory):
router = APIRouter(prefix="/api/rooms")
return router
+6
View File
@@ -0,0 +1,6 @@
from fastapi import APIRouter
def make_router(settings, db_factory):
router = APIRouter(prefix="/api")
return router
+6
View File
@@ -0,0 +1,6 @@
from fastapi import APIRouter
def make_router(settings, db_factory):
router = APIRouter(prefix="/api/speakers")
return router
+100
View File
@@ -0,0 +1,100 @@
import sqlite3
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel
class TagRequest(BaseModel):
speaker_id: Optional[int] = None
name: Optional[str] = None
create_new: bool = False
def make_router(settings, db_factory):
router = APIRouter(prefix="/api/utterances")
@router.get("")
def list_utterances(
date: Optional[str] = None,
room_id: Optional[int] = None,
speaker_id: Optional[int] = None,
match_status: Optional[str] = None,
):
db: sqlite3.Connection = db_factory()
query = """
SELECT u.*, r.name as room_name, s.name as speaker_name
FROM utterances u
JOIN rooms r ON u.room_id = r.id
LEFT JOIN speakers s ON u.speaker_id = s.id
WHERE 1=1
"""
params: list = []
if date:
query += " AND date(u.start_time) = ?"
params.append(date)
if room_id:
query += " AND u.room_id = ?"
params.append(room_id)
if speaker_id:
query += " AND u.speaker_id = ?"
params.append(speaker_id)
if match_status:
query += " AND u.match_status = ?"
params.append(match_status)
query += " ORDER BY u.start_time DESC LIMIT 200"
rows = db.execute(query, params).fetchall()
return [dict(r) for r in rows]
@router.get("/{utterance_id}/clip")
def get_clip(utterance_id: int):
db: sqlite3.Connection = db_factory()
row = db.execute(
"SELECT audio_clip_path FROM utterances WHERE id=?", (utterance_id,)
).fetchone()
if not row or not row["audio_clip_path"]:
raise HTTPException(404, "Clip not found or expired")
return FileResponse(row["audio_clip_path"], media_type="audio/wav")
@router.post("/{utterance_id}/tag")
def tag_utterance(utterance_id: int, body: TagRequest):
db: sqlite3.Connection = db_factory()
row = db.execute("SELECT * FROM utterances WHERE id=?", (utterance_id,)).fetchone()
if not row:
raise HTTPException(404, "Utterance not found")
speaker_id = body.speaker_id
if body.create_new and body.name:
cur = db.execute("INSERT INTO speakers (name) VALUES (?)", (body.name,))
db.commit()
speaker_id = cur.lastrowid
if speaker_id is None:
raise HTTPException(400, "Provide speaker_id or name+create_new")
db.execute(
"UPDATE utterances SET speaker_id=?, match_status='known', match_confidence=1.0 WHERE id=?",
(speaker_id, utterance_id),
)
db.commit()
# Store utterance embedding as a reference sample for this speaker
if row["embedding"]:
db.execute(
"INSERT INTO voice_embeddings (speaker_id, embedding) VALUES (?,?)",
(speaker_id, row["embedding"]),
)
db.commit()
return {"ok": True, "speaker_id": speaker_id}
@router.post("/{utterance_id}/dismiss")
def dismiss_utterance(utterance_id: int):
db: sqlite3.Connection = db_factory()
db.execute(
"UPDATE utterances SET match_status='unknown' WHERE id=?", (utterance_id,)
)
db.commit()
return {"ok": True}
return router