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
50 changes: 50 additions & 0 deletions chatbot-core/api/models/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,56 @@ class DeleteResponse(BaseModel):
"""
message: str


class SessionSummary(BaseModel):
"""
Summary of a single session for the session list endpoint.

Fields:
session_id (str): The unique session identifier.
message_count (int): Number of messages in the session.
"""
session_id: str
message_count: int


class SessionListResponse(BaseModel):
"""
Paginated response model for listing all active sessions.

Fields:
sessions (List[SessionSummary]): Paginated list of session summaries.
total (int): Total number of sessions available.
offset (int): Starting index of the returned page.
limit (int): Maximum number of sessions per page.
"""
sessions: List[SessionSummary]
total: int
offset: int
limit: int

class MessageItem(BaseModel):
"""
Represents a single message in the conversation history.

Fields:
role (str): The role of the message sender ('human' or 'ai').
content (str): The text content of the message.
"""
role: str
content: str

class MessageHistoryResponse(BaseModel):
"""
Response model for retrieving the conversation history of a session.

Fields:
session_id (str): The session identifier.
messages (List[MessageItem]): Ordered list of messages in the session.
"""
session_id: str
messages: List[MessageItem]

class QueryType(Enum):
"""
Enum that represents the possible query types:
Expand Down
22 changes: 22 additions & 0 deletions chatbot-core/api/routes/chatbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
ChatResponse,
DeleteResponse,
SessionResponse,
SessionListResponse,
FileAttachment,
SupportedExtensionsResponse,
)
Expand All @@ -50,6 +51,7 @@
session_exists,
persist_session,
init_session,
get_all_sessions,
)
from api.services.file_service import (
process_uploaded_file,
Expand Down Expand Up @@ -166,6 +168,26 @@ def start_chat(response: Response):
)
return SessionResponse(session_id=session_id)


@router.get(
"/sessions",
response_model=SessionListResponse,
)
def list_sessions(offset: int = 0, limit: int = 20):
"""
List all active chat sessions with pagination.

Returns session IDs with basic metadata (message count).
Note: Once authentication is implemented, this will be
filtered to only return the current user's sessions.

Query Parameters:
offset (int): Starting index (default: 0).
limit (int): Max sessions per page (default: 20).
"""
result = get_all_sessions(offset=offset, limit=limit)
return SessionListResponse(**result)

@router.delete(
"/sessions/{session_id}",
response_model=DeleteResponse,
Expand Down
35 changes: 35 additions & 0 deletions chatbot-core/api/services/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,41 @@ def reset_sessions():
with _lock:
_sessions.clear()


def get_all_sessions(offset: int = 0, limit: int = 20,
user_id: str = None) -> dict: # pylint: disable=unused-argument
"""
Return a paginated list of active sessions with basic metadata.

Each entry contains the session ID and its message count.

Args:
offset (int): Starting index for pagination (default: 0).
limit (int): Maximum number of sessions to return (default: 20).
user_id (str): Optional user ID to filter sessions.
Currently unused — once authentication is implemented
(#78), this should filter to only that user's sessions.

Returns:
dict: Contains 'sessions' (paginated list), 'total', 'offset', 'limit'.
"""
with _lock:
all_sessions = []
for session_id, session_data in _sessions.items():
memory = session_data["memory"]
all_sessions.append({
"session_id": session_id,
"message_count": len(memory.chat_memory.messages),
})
total = len(all_sessions)
paginated = all_sessions[offset:offset + limit]
return {
"sessions": paginated,
"total": total,
"offset": offset,
"limit": limit,
}

def get_last_accessed(session_id: str) -> Optional[datetime]:
"""
Get the last accessed timestamp for a given session.
Expand Down
109 changes: 109 additions & 0 deletions chatbot-core/tests/integration/test_chatbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,112 @@ def get_relevant_documents_output():
"id": "docid",
"chunk_text": "Relevant chunk text."
}],[0.84])


# =========================
# GET /sessions tests
# =========================

def test_list_sessions_empty(client):
"""Should return an empty list when no sessions exist."""
response = client.get("/sessions")
assert response.status_code == 200
data = response.json()
assert data["sessions"] == []
assert data["total"] == 0
assert data["offset"] == 0
assert data["limit"] == 20


def test_list_sessions_single(client):
"""Should return one session after creating it."""
create_resp = client.post("/sessions")
session_id = create_resp.json()["session_id"]

response = client.get("/sessions")
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert len(data["sessions"]) == 1
assert data["sessions"][0]["session_id"] == session_id
assert data["sessions"][0]["message_count"] == 0


def test_list_sessions_with_messages(client, mock_llm_provider, mock_get_relevant_documents):
"""Should reflect correct message counts per session."""
mock_llm_provider.generate.return_value = "Reply"
mock_get_relevant_documents.return_value = get_relevant_documents_output()

s1 = client.post("/sessions").json()["session_id"]
s2 = client.post("/sessions").json()["session_id"]

# Send 2 messages to s1, none to s2
client.post(f"/sessions/{s1}/message", json={"message": "Hi"})
client.post(f"/sessions/{s1}/message", json={"message": "Again"})

response = client.get("/sessions")
data = response.json()
sessions_map = {s["session_id"]: s["message_count"] for s in data["sessions"]}

assert data["total"] == 2
assert sessions_map[s1] == 4 # 2 human + 2 AI
assert sessions_map[s2] == 0


def test_list_sessions_after_deletion(client):
"""Deleted sessions should not appear in the list."""
s1 = client.post("/sessions").json()["session_id"]
s2 = client.post("/sessions").json()["session_id"]

client.delete(f"/sessions/{s1}")

response = client.get("/sessions")
data = response.json()
session_ids = [s["session_id"] for s in data["sessions"]]

assert data["total"] == 1
assert s1 not in session_ids
assert s2 in session_ids


def test_list_sessions_pagination_default(client):
"""Should include pagination metadata with default values."""
client.post("/sessions")
client.post("/sessions")

response = client.get("/sessions")
data = response.json()

assert data["total"] == 2
assert data["offset"] == 0
assert data["limit"] == 20
assert len(data["sessions"]) == 2


def test_list_sessions_pagination_custom(client):
"""Custom offset and limit should return the correct slice."""
session_ids = []
for _ in range(5):
resp = client.post("/sessions")
session_ids.append(resp.json()["session_id"])

response = client.get("/sessions", params={"offset": 2, "limit": 2})
data = response.json()

assert data["total"] == 5
assert data["offset"] == 2
assert data["limit"] == 2
assert len(data["sessions"]) == 2


def test_list_sessions_pagination_beyond(client):
"""Offset past total should return an empty sessions list."""
client.post("/sessions")
client.post("/sessions")

response = client.get("/sessions", params={"offset": 100})
data = response.json()

assert data["total"] == 2
assert data["offset"] == 100
assert data["sessions"] == []