feat: API routes — speakers, rooms, search, RAG

Full implementations replacing stubs: speakers CRUD with utterance count,
rooms list + mute/unmute via pipeline, FTS5-based search, RAG /ask and
/summary endpoints. 15 route tests (8 new) all passing; 35 total.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 10:46:24 -05:00
parent 4def6a526c
commit 59014f6fe7
8 changed files with 221 additions and 4 deletions
+62 -2
View File
@@ -1,6 +1,66 @@
from fastapi import APIRouter import sqlite3
import httpx
from typing import Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from app.shared.config import Settings
def make_router(settings, db_factory): class AskRequest(BaseModel):
question: str
date: Optional[str] = None
def make_router(settings: Settings, db_factory):
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
@router.post("/ask")
def ask(body: AskRequest):
db: sqlite3.Connection = db_factory()
rows = db.execute("""
SELECT u.start_time, u.transcript, 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 date(u.start_time) = date(?)
ORDER BY u.start_time ASC
LIMIT 200
""", (body.date or "now",)).fetchall()
context_lines = [
f"[{r['room_name']} {r['start_time']}] {r['speaker_name'] or 'Unknown'}: {r['transcript']}"
for r in rows
]
context = "\n".join(context_lines) if context_lines else "(no transcripts for this date)"
prompt = (
f"You are a helpful assistant with access to household conversation transcripts.\n\n"
f"Transcripts:\n{context}\n\n"
f"Question: {body.question}\n\n"
f"Answer based only on the transcripts above. Cite the time and room when relevant."
)
resp = httpx.post(
f"{settings.ollama_url}/api/chat",
json={
"model": settings.ollama_model,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
},
timeout=60.0,
)
resp.raise_for_status()
answer = resp.json()["message"]["content"]
return {"answer": answer, "context_count": len(rows)}
@router.get("/summary/{date}")
def get_summary(date: str):
db: sqlite3.Connection = db_factory()
row = db.execute(
"SELECT * FROM daily_summaries WHERE date=?", (date,)
).fetchone()
if not row:
raise HTTPException(404, f"No summary for {date}")
return dict(row)
return router return router
+32 -2
View File
@@ -1,6 +1,36 @@
from fastapi import APIRouter import sqlite3
import httpx
from fastapi import APIRouter, HTTPException
from app.shared.config import Settings
def make_router(settings, db_factory): def make_router(settings: Settings, db_factory):
router = APIRouter(prefix="/api/rooms") router = APIRouter(prefix="/api/rooms")
@router.get("")
def list_rooms():
db: sqlite3.Connection = db_factory()
rows = db.execute(
"SELECT id, name, device_label, is_active, last_seen, created_at FROM rooms ORDER BY name"
).fetchall()
return [dict(r) for r in rows]
@router.post("/{room_id}/mute")
def mute_room(room_id: int):
db: sqlite3.Connection = db_factory()
row = db.execute("SELECT name FROM rooms WHERE id=?", (room_id,)).fetchone()
if not row:
raise HTTPException(404, "Room not found")
httpx.post(f"{settings.pipeline_internal_url}/internal/rooms/{row['name']}/mute")
return {"ok": True, "room": row["name"]}
@router.post("/{room_id}/unmute")
def unmute_room(room_id: int):
db: sqlite3.Connection = db_factory()
row = db.execute("SELECT name FROM rooms WHERE id=?", (room_id,)).fetchone()
if not row:
raise HTTPException(404, "Room not found")
httpx.post(f"{settings.pipeline_internal_url}/internal/rooms/{row['name']}/unmute")
return {"ok": True, "room": row["name"]}
return router return router
+20
View File
@@ -1,6 +1,26 @@
import sqlite3
from typing import Optional
from fastapi import APIRouter from fastapi import APIRouter
def make_router(settings, db_factory): def make_router(settings, db_factory):
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
@router.get("/search")
def search(q: str, date: Optional[str] = None):
if not q.strip():
return []
db: sqlite3.Connection = db_factory()
rows = db.execute("""
SELECT u.*, r.name as room_name, s.name as speaker_name
FROM utterances_fts fts
JOIN utterances u ON u.id = fts.rowid
JOIN rooms r ON u.room_id = r.id
LEFT JOIN speakers s ON u.speaker_id = s.id
WHERE utterances_fts MATCH ?
ORDER BY u.start_time DESC
LIMIT 100
""", (q.strip(),)).fetchall()
return [dict(r) for r in rows]
return router return router
+28
View File
@@ -1,6 +1,34 @@
import sqlite3
from fastapi import APIRouter from fastapi import APIRouter
from pydantic import BaseModel
class CreateSpeakerRequest(BaseModel):
name: str
def make_router(settings, db_factory): def make_router(settings, db_factory):
router = APIRouter(prefix="/api/speakers") router = APIRouter(prefix="/api/speakers")
@router.get("")
def list_speakers():
db: sqlite3.Connection = db_factory()
rows = db.execute("""
SELECT s.id, s.name, s.created_at,
COUNT(u.id) as utterance_count
FROM speakers s
LEFT JOIN utterances u ON u.speaker_id = s.id
GROUP BY s.id
ORDER BY s.name
""").fetchall()
return [dict(r) for r in rows]
@router.post("")
def create_speaker(body: CreateSpeakerRequest):
db: sqlite3.Connection = db_factory()
cur = db.execute("INSERT INTO speakers (name) VALUES (?)", (body.name,))
db.commit()
row = db.execute("SELECT * FROM speakers WHERE id=?", (cur.lastrowid,)).fetchone()
return dict(row)
return router return router
+21
View File
@@ -0,0 +1,21 @@
def test_ask_returns_response(api_client, seeded_db, mocker):
mock_post = mocker.patch("app.api.routes.rag.httpx.post")
mock_post.return_value.json.return_value = {
"message": {"content": "You talked about groceries."}
}
mock_post.return_value.raise_for_status = lambda: None
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"], "we need milk from the store", "unknown", "2026-01-01 10:00:00", "2026-01-01 10:00:03"),
)
seeded_db.commit()
resp = api_client.post("/api/ask", json={"question": "What did we need from the store?"})
assert resp.status_code == 200
data = resp.json()
assert "answer" in data
def test_get_summary_not_found(api_client):
resp = api_client.get("/api/summary/2026-01-01")
assert resp.status_code == 404
+14
View File
@@ -0,0 +1,14 @@
def test_list_rooms(api_client, seeded_db):
resp = api_client.get("/api/rooms")
assert resp.status_code == 200
data = resp.json()
assert any(r["name"] == "Kitchen" for r in data)
def test_mute_room_calls_pipeline(api_client, seeded_db, mocker):
mock_post = mocker.patch("app.api.routes.rooms.httpx.post")
mock_post.return_value.status_code = 200
room = seeded_db.execute("SELECT id FROM rooms WHERE name='Kitchen'").fetchone()
resp = api_client.post(f"/api/rooms/{room['id']}/mute")
assert resp.status_code == 200
mock_post.assert_called_once()
+18
View File
@@ -0,0 +1,18 @@
def test_search_returns_matches(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"], "the grocery list is ready", "unknown", "2026-01-01 10:00:00", "2026-01-01 10:00:03"),
)
seeded_db.commit()
resp = api_client.get("/api/search?q=grocery")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert "grocery" in data[0]["transcript"]
def test_search_no_results(api_client):
resp = api_client.get("/api/search?q=xyznotfound")
assert resp.status_code == 200
assert resp.json() == []
+26
View File
@@ -0,0 +1,26 @@
def test_list_speakers_empty(api_client):
resp = api_client.get("/api/speakers")
assert resp.status_code == 200
assert resp.json() == []
def test_create_speaker(api_client):
resp = api_client.post("/api/speakers", json={"name": "Alice"})
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "Alice"
assert "id" in data
def test_list_speakers_includes_utterance_count(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, speaker_id, transcript, match_status, start_time, end_time) VALUES (?,?,?,?,?,?)",
(room["id"], speaker["id"], "hi", "known", "2026-01-01 10:00:00", "2026-01-01 10:00:01"),
)
seeded_db.commit()
resp = api_client.get("/api/speakers")
data = resp.json()
jeremy = next(s for s in data if s["name"] == "Jeremy")
assert jeremy["utterance_count"] == 1