Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 136 additions & 6 deletions backend/app/main.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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,
Expand Down
157 changes: 157 additions & 0 deletions backend/tests/test_conversations.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
Loading