diff --git a/app/api/routes/rag.py b/app/api/routes/rag.py index 131609f..7f3126d 100644 --- a/app/api/routes/rag.py +++ b/app/api/routes/rag.py @@ -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 diff --git a/app/api/routes/rooms.py b/app/api/routes/rooms.py index 268f683..033f9ec 100644 --- a/app/api/routes/rooms.py +++ b/app/api/routes/rooms.py @@ -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 diff --git a/app/api/routes/search.py b/app/api/routes/search.py index 131609f..7945789 100644 --- a/app/api/routes/search.py +++ b/app/api/routes/search.py @@ -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 diff --git a/app/api/routes/speakers.py b/app/api/routes/speakers.py index 074bab6..e03dbc0 100644 --- a/app/api/routes/speakers.py +++ b/app/api/routes/speakers.py @@ -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 diff --git a/tests/routes/test_rag.py b/tests/routes/test_rag.py new file mode 100644 index 0000000..10b1aaf --- /dev/null +++ b/tests/routes/test_rag.py @@ -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 diff --git a/tests/routes/test_rooms.py b/tests/routes/test_rooms.py new file mode 100644 index 0000000..4d1b3a3 --- /dev/null +++ b/tests/routes/test_rooms.py @@ -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() diff --git a/tests/routes/test_search.py b/tests/routes/test_search.py new file mode 100644 index 0000000..e36228d --- /dev/null +++ b/tests/routes/test_search.py @@ -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() == [] diff --git a/tests/routes/test_speakers.py b/tests/routes/test_speakers.py new file mode 100644 index 0000000..be5d501 --- /dev/null +++ b/tests/routes/test_speakers.py @@ -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