From 4def6a526c4aea151d9b1ef13a232e74cadfa7fb Mon Sep 17 00:00:00 2001 From: Pluto Date: Thu, 28 May 2026 10:43:22 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20utterances=20API=20routes=20=E2=80=94?= =?UTF-8?q?=20list,=20clip,=20tag,=20dismiss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/api/main.py | 45 ++++++++++++++ app/api/routes/rag.py | 6 ++ app/api/routes/rooms.py | 6 ++ app/api/routes/search.py | 6 ++ app/api/routes/speakers.py | 6 ++ app/api/routes/utterances.py | 100 ++++++++++++++++++++++++++++++++ tests/routes/conftest.py | 18 ++++++ tests/routes/test_utterances.py | 86 +++++++++++++++++++++++++++ 8 files changed, 273 insertions(+) create mode 100644 app/api/main.py create mode 100644 app/api/routes/rag.py create mode 100644 app/api/routes/rooms.py create mode 100644 app/api/routes/search.py create mode 100644 app/api/routes/speakers.py create mode 100644 app/api/routes/utterances.py create mode 100644 tests/routes/conftest.py create mode 100644 tests/routes/test_utterances.py diff --git a/app/api/main.py b/app/api/main.py new file mode 100644 index 0000000..27fbf23 --- /dev/null +++ b/app/api/main.py @@ -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 diff --git a/app/api/routes/rag.py b/app/api/routes/rag.py new file mode 100644 index 0000000..131609f --- /dev/null +++ b/app/api/routes/rag.py @@ -0,0 +1,6 @@ +from fastapi import APIRouter + + +def make_router(settings, db_factory): + router = APIRouter(prefix="/api") + return router diff --git a/app/api/routes/rooms.py b/app/api/routes/rooms.py new file mode 100644 index 0000000..268f683 --- /dev/null +++ b/app/api/routes/rooms.py @@ -0,0 +1,6 @@ +from fastapi import APIRouter + + +def make_router(settings, db_factory): + router = APIRouter(prefix="/api/rooms") + return router diff --git a/app/api/routes/search.py b/app/api/routes/search.py new file mode 100644 index 0000000..131609f --- /dev/null +++ b/app/api/routes/search.py @@ -0,0 +1,6 @@ +from fastapi import APIRouter + + +def make_router(settings, db_factory): + router = APIRouter(prefix="/api") + return router diff --git a/app/api/routes/speakers.py b/app/api/routes/speakers.py new file mode 100644 index 0000000..074bab6 --- /dev/null +++ b/app/api/routes/speakers.py @@ -0,0 +1,6 @@ +from fastapi import APIRouter + + +def make_router(settings, db_factory): + router = APIRouter(prefix="/api/speakers") + return router diff --git a/app/api/routes/utterances.py b/app/api/routes/utterances.py new file mode 100644 index 0000000..3b4a3c3 --- /dev/null +++ b/app/api/routes/utterances.py @@ -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 diff --git a/tests/routes/conftest.py b/tests/routes/conftest.py new file mode 100644 index 0000000..b8456f1 --- /dev/null +++ b/tests/routes/conftest.py @@ -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 diff --git a/tests/routes/test_utterances.py b/tests/routes/test_utterances.py new file mode 100644 index 0000000..4984459 --- /dev/null +++ b/tests/routes/test_utterances.py @@ -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"