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
+18
View File
@@ -0,0 +1,18 @@
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def api_client(settings):
from app.api.main import create_app
app = create_app(settings)
return TestClient(app)
@pytest.fixture
def seeded_db(db):
"""DB with a room and speaker pre-inserted."""
db.execute("INSERT INTO rooms (name) VALUES (?)", ("Kitchen",))
db.execute("INSERT INTO speakers (name) VALUES (?)", ("Jeremy",))
db.commit()
return db
+86
View File
@@ -0,0 +1,86 @@
def test_list_utterances_empty(api_client):
resp = api_client.get("/api/utterances")
assert resp.status_code == 200
assert resp.json() == []
def test_list_utterances_with_data(api_client, seeded_db):
room = seeded_db.execute("SELECT id FROM rooms WHERE name='Kitchen'").fetchone()
seeded_db.execute(
"INSERT INTO utterances (room_id, transcript, match_status, start_time, end_time) "
"VALUES (?,?,?,?,?)",
(room["id"], "hello world", "unknown", "2026-01-01 10:00:00", "2026-01-01 10:00:03"),
)
seeded_db.commit()
resp = api_client.get("/api/utterances")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["transcript"] == "hello world"
assert data[0]["room_name"] == "Kitchen"
def test_filter_utterances_by_date(api_client, seeded_db):
room = seeded_db.execute("SELECT id FROM rooms WHERE name='Kitchen'").fetchone()
seeded_db.execute(
"INSERT INTO utterances (room_id, transcript, match_status, start_time, end_time) VALUES (?,?,?,?,?)",
(room["id"], "today speech", "unknown", "2026-05-27 10:00:00", "2026-05-27 10:00:03"),
)
seeded_db.execute(
"INSERT INTO utterances (room_id, transcript, match_status, start_time, end_time) VALUES (?,?,?,?,?)",
(room["id"], "yesterday speech", "unknown", "2026-05-26 10:00:00", "2026-05-26 10:00:03"),
)
seeded_db.commit()
resp = api_client.get("/api/utterances?date=2026-05-27")
data = resp.json()
assert len(data) == 1
assert data[0]["transcript"] == "today speech"
def test_tag_utterance_existing_speaker(api_client, seeded_db):
room = seeded_db.execute("SELECT id FROM rooms WHERE name='Kitchen'").fetchone()
speaker = seeded_db.execute("SELECT id FROM speakers WHERE name='Jeremy'").fetchone()
seeded_db.execute(
"INSERT INTO utterances (room_id, transcript, match_status, start_time, end_time, embedding) VALUES (?,?,?,?,?,?)",
(room["id"], "tag me", "unknown", "2026-01-01 10:00:00", "2026-01-01 10:00:03",
b"\x00" * 1024),
)
seeded_db.commit()
utt = seeded_db.execute("SELECT id FROM utterances").fetchone()
resp = api_client.post(f"/api/utterances/{utt['id']}/tag", json={"speaker_id": speaker["id"]})
assert resp.status_code == 200
updated = seeded_db.execute("SELECT * FROM utterances WHERE id=?", (utt["id"],)).fetchone()
assert updated["speaker_id"] == speaker["id"]
assert updated["match_status"] == "known"
assert updated["match_confidence"] == 1.0
def test_tag_utterance_create_new_speaker(api_client, seeded_db):
room = seeded_db.execute("SELECT id FROM rooms WHERE name='Kitchen'").fetchone()
seeded_db.execute(
"INSERT INTO utterances (room_id, transcript, match_status, start_time, end_time) VALUES (?,?,?,?,?)",
(room["id"], "new speaker", "unknown", "2026-01-01 10:00:00", "2026-01-01 10:00:03"),
)
seeded_db.commit()
utt = seeded_db.execute("SELECT id FROM utterances WHERE transcript='new speaker'").fetchone()
resp = api_client.post(
f"/api/utterances/{utt['id']}/tag",
json={"name": "Alice", "create_new": True},
)
assert resp.status_code == 200
alice = seeded_db.execute("SELECT id FROM speakers WHERE name='Alice'").fetchone()
assert alice is not None
def test_dismiss_utterance(api_client, seeded_db):
room = seeded_db.execute("SELECT id FROM rooms WHERE name='Kitchen'").fetchone()
seeded_db.execute(
"INSERT INTO utterances (room_id, transcript, match_status, start_time, end_time) VALUES (?,?,?,?,?)",
(room["id"], "dismiss me", "unreviewed", "2026-01-01 10:00:00", "2026-01-01 10:00:03"),
)
seeded_db.commit()
utt = seeded_db.execute("SELECT id FROM utterances WHERE transcript='dismiss me'").fetchone()
resp = api_client.post(f"/api/utterances/{utt['id']}/dismiss")
assert resp.status_code == 200
updated = seeded_db.execute("SELECT match_status FROM utterances WHERE id=?", (utt["id"],)).fetchone()
assert updated["match_status"] == "unknown"