From fe3686ac4f074b068f9c31ddcb1887fdc359c8ea Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:39:03 +0900 Subject: [PATCH] feat(api): add conversation list and detail endpoints with pagination --- backend/app/main.py | 142 +++++++++++++++++++++++-- backend/tests/test_conversations.py | 157 ++++++++++++++++++++++++++++ mise.toml | 2 +- 3 files changed, 294 insertions(+), 7 deletions(-) create mode 100644 backend/tests/test_conversations.py diff --git a/backend/app/main.py b/backend/app/main.py index e929e07..7b6f399 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,12 +1,15 @@ """ FastAPI main application """ -from fastapi import FastAPI, HTTPException, Depends +from datetime import datetime +from typing import Any, Dict, List, Optional + +from fastapi import Depends, FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware -from starlette.concurrency import run_in_threadpool -from sqlalchemy.orm import Session -from typing import List, Dict, Any from pydantic import BaseModel +from sqlalchemy import func +from sqlalchemy.orm import Session +from starlette.concurrency import run_in_threadpool import logging from app.config import settings @@ -15,7 +18,7 @@ configure_logging(service="wingman-backend") -from app.database import get_db, init_db, SlackMessage, Document +from app.database import ConversationHistory, get_db, init_db, SlackMessage, Document from app.rag import rag_engine from app.slack_bot import slack_bot @@ -66,11 +69,49 @@ class MessageResponse(BaseModel): channel_id: str user_id: str text: str - + + class Config: + from_attributes = True + + +class ConversationSummary(BaseModel): + conversation_id: str + channel_id: Optional[str] = None + message_count: int + first_message_at: Optional[datetime] = None + last_message_at: Optional[datetime] = None + last_message_preview: Optional[str] = None + + +class ConversationListResponse(BaseModel): + items: List[ConversationSummary] + total: int + limit: int + offset: int + + +class ConversationTurn(BaseModel): + id: int + conversation_id: str + user_id: Optional[str] = None + channel_id: Optional[str] = None + role: str + message: str + message_ts: Optional[str] = None + created_at: Optional[datetime] = None + class Config: from_attributes = True +class ConversationDetailResponse(BaseModel): + conversation_id: str + items: List[ConversationTurn] + total: int + limit: int + offset: int + + # Startup/Shutdown Events @app.on_event("startup") async def startup_event(): @@ -198,6 +239,95 @@ async def list_messages( raise HTTPException(status_code=500, detail=str(e)) +@app.get("/api/conversations", response_model=ConversationListResponse) +async def list_conversations( + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + channel_id: str = None, + db: Session = Depends(get_db), +): + """List one summary per conversation, most recently active first.""" + base = db.query(ConversationHistory) + if channel_id: + base = base.filter(ConversationHistory.channel_id == channel_id) + + total = base.with_entities( + func.count(func.distinct(ConversationHistory.conversation_id)) + ).scalar() or 0 + + rows = ( + base.with_entities( + ConversationHistory.conversation_id, + func.max(ConversationHistory.channel_id), + func.count(ConversationHistory.id), + func.min(ConversationHistory.created_at), + func.max(ConversationHistory.created_at), + ) + .group_by(ConversationHistory.conversation_id) + .order_by(func.max(ConversationHistory.created_at).desc()) + .offset(offset) + .limit(limit) + .all() + ) + + items = [] + for conversation_id, row_channel_id, count, first_at, last_at in rows: + last_row = ( + db.query(ConversationHistory) + .filter(ConversationHistory.conversation_id == conversation_id) + .order_by( + ConversationHistory.created_at.desc(), ConversationHistory.id.desc() + ) + .first() + ) + preview = None + if last_row is not None and last_row.message: + preview = last_row.message[:120] + items.append( + ConversationSummary( + conversation_id=conversation_id, + channel_id=row_channel_id, + message_count=count, + first_message_at=first_at, + last_message_at=last_at, + last_message_preview=preview, + ) + ) + return ConversationListResponse(items=items, total=total, limit=limit, offset=offset) + + +@app.get("/api/conversations/{conversation_id}", response_model=ConversationDetailResponse) +async def get_conversation( + conversation_id: str, + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + db: Session = Depends(get_db), +): + """Return the turns of one conversation in chronological order.""" + total = ( + db.query(ConversationHistory) + .filter(ConversationHistory.conversation_id == conversation_id) + .count() + ) + if total == 0: + raise HTTPException(status_code=404, detail="Conversation not found") + turns = ( + db.query(ConversationHistory) + .filter(ConversationHistory.conversation_id == conversation_id) + .order_by(ConversationHistory.created_at.asc(), ConversationHistory.id.asc()) + .offset(offset) + .limit(limit) + .all() + ) + return ConversationDetailResponse( + conversation_id=conversation_id, + items=turns, + total=total, + limit=limit, + offset=offset, + ) + + @app.post("/api/index/thread") async def index_thread( channel_id: str, diff --git a/backend/tests/test_conversations.py b/backend/tests/test_conversations.py new file mode 100644 index 0000000..abdfdfc --- /dev/null +++ b/backend/tests/test_conversations.py @@ -0,0 +1,157 @@ +"""Tests for the conversation list and detail endpoints.""" +import os + +os.environ.setdefault("SLACK_BOT_TOKEN", "xoxb-test-stub") +os.environ.setdefault("SLACK_SIGNING_SECRET", "test-signing-secret") + +from datetime import datetime, timedelta + +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app.database import Base, ConversationHistory, get_db + +with patch("slack_sdk.WebClient.auth_test", return_value={ + "ok": True, "bot_id": "BTEST", "user_id": "UTEST", "team_id": "TTEST" +}): + from app.main import app + +engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, +) +TestingSession = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base.metadata.create_all(bind=engine) + + +def override_get_db(): + db = TestingSession() + try: + yield db + finally: + db.close() + + +app.dependency_overrides[get_db] = override_get_db +client = TestClient(app, raise_server_exceptions=False) + +BASE_TIME = datetime(2026, 1, 1, 9, 0, 0) + + +def _turn(conversation_id, channel_id, role, message, minutes, user_id="U1"): + return ConversationHistory( + conversation_id=conversation_id, + user_id=user_id, + channel_id=channel_id, + role=role, + message=message, + created_at=BASE_TIME + timedelta(minutes=minutes), + ) + + +@pytest.fixture(autouse=True) +def seed(): + db = TestingSession() + db.query(ConversationHistory).delete() + db.commit() + db.add_all([ + _turn("conv-a", "C1", "user", "How do I reset VPN?", 0), + _turn("conv-a", "C1", "assistant", "Use the IT portal.", 1), + _turn("conv-a", "C1", "user", "Thanks, that worked.", 5), + _turn("conv-b", "C2", "user", "When is the office open?", 2), + _turn("conv-b", "C2", "assistant", "Nine to five.", 3), + _turn("conv-c", "C1", "user", "x" * 200, 10), + ]) + db.commit() + db.close() + yield + db = TestingSession() + db.query(ConversationHistory).delete() + db.commit() + db.close() + + +class TestListConversations: + def test_lists_summaries_most_recent_first(self): + response = client.get("/api/conversations") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 3 + assert data["limit"] == 50 + assert data["offset"] == 0 + assert [item["conversation_id"] for item in data["items"]] == [ + "conv-c", "conv-a", "conv-b", + ] + first = data["items"][0] + assert first["channel_id"] == "C1" + assert first["message_count"] == 1 + assert first["last_message_preview"] == "x" * 120 + + def test_summary_counts_and_preview(self): + response = client.get("/api/conversations") + by_id = {i["conversation_id"]: i for i in response.json()["items"]} + assert by_id["conv-a"]["message_count"] == 3 + assert by_id["conv-a"]["last_message_preview"] == "Thanks, that worked." + assert by_id["conv-a"]["first_message_at"] is not None + assert by_id["conv-a"]["last_message_at"] is not None + + def test_pagination(self): + page_one = client.get("/api/conversations?limit=2&offset=0").json() + page_two = client.get("/api/conversations?limit=2&offset=2").json() + assert page_one["total"] == 3 + assert len(page_one["items"]) == 2 + assert len(page_two["items"]) == 1 + assert page_one["items"][0]["conversation_id"] != page_two["items"][0]["conversation_id"] + + def test_channel_filter(self): + data = client.get("/api/conversations?channel_id=C2").json() + assert data["total"] == 1 + assert data["items"][0]["conversation_id"] == "conv-b" + + def test_invalid_pagination_rejected(self): + assert client.get("/api/conversations?limit=0").status_code == 422 + assert client.get("/api/conversations?limit=201").status_code == 422 + assert client.get("/api/conversations?offset=-1").status_code == 422 + + def test_empty_database(self): + db = TestingSession() + db.query(ConversationHistory).delete() + db.commit() + db.close() + data = client.get("/api/conversations").json() + assert data == {"items": [], "total": 0, "limit": 50, "offset": 0} + + +class TestConversationDetail: + def test_returns_turns_in_chronological_order(self): + response = client.get("/api/conversations/conv-a") + assert response.status_code == 200 + data = response.json() + assert data["conversation_id"] == "conv-a" + assert data["total"] == 3 + assert [item["message"] for item in data["items"]] == [ + "How do I reset VPN?", + "Use the IT portal.", + "Thanks, that worked.", + ] + assert [item["role"] for item in data["items"]] == ["user", "assistant", "user"] + + def test_detail_pagination(self): + data = client.get("/api/conversations/conv-a?limit=1&offset=1").json() + assert data["total"] == 3 + assert len(data["items"]) == 1 + assert data["items"][0]["message"] == "Use the IT portal." + + def test_missing_conversation_is_404(self): + response = client.get("/api/conversations/does-not-exist") + assert response.status_code == 404 + assert response.json()["detail"] == "Conversation not found" + + def test_detail_invalid_pagination_rejected(self): + assert client.get("/api/conversations/conv-a?limit=0").status_code == 422 diff --git a/mise.toml b/mise.toml index 9b26183..73970ec 100644 --- a/mise.toml +++ b/mise.toml @@ -51,7 +51,7 @@ run = "docker compose ps" [tasks.test-backend] description = "Run backend tests" dir = "backend" -run = "uv run pytest tests/test_file_extraction_units.py tests/test_logging_config.py tests/test_logging.py tests/test_agent_runtime.py tests/test_rules.py -v" +run = "uv run pytest tests/test_file_extraction_units.py tests/test_logging_config.py tests/test_logging.py tests/test_agent_runtime.py tests/test_rules.py tests/test_conversations.py -v" [tasks.migrate] description = "Run database migrations (upgrade to latest)"