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:
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
|
||||||
|
def make_router(settings, db_factory):
|
||||||
|
router = APIRouter(prefix="/api")
|
||||||
|
return router
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
|
||||||
|
def make_router(settings, db_factory):
|
||||||
|
router = APIRouter(prefix="/api/rooms")
|
||||||
|
return router
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
|
||||||
|
def make_router(settings, db_factory):
|
||||||
|
router = APIRouter(prefix="/api")
|
||||||
|
return router
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
|
||||||
|
def make_router(settings, db_factory):
|
||||||
|
router = APIRouter(prefix="/api/speakers")
|
||||||
|
return router
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user