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:
+62
-2
@@ -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.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
|
||||
|
||||
+32
-2
@@ -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.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
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
import sqlite3
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter
|
||||
|
||||
|
||||
def make_router(settings, db_factory):
|
||||
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
|
||||
|
||||
@@ -1,6 +1,34 @@
|
||||
import sqlite3
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CreateSpeakerRequest(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
def make_router(settings, db_factory):
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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() == []
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user