diff --git a/src/ea_chatbot/api/main.py b/src/ea_chatbot/api/main.py index 37e1f05..a9cb837 100644 --- a/src/ea_chatbot/api/main.py +++ b/src/ea_chatbot/api/main.py @@ -1,6 +1,6 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from ea_chatbot.api.routers import auth +from ea_chatbot.api.routers import auth, history, artifacts app = FastAPI( title="Election Analytics Chatbot API", @@ -18,6 +18,8 @@ app.add_middleware( ) app.include_router(auth.router) +app.include_router(history.router) +app.include_router(artifacts.router) @app.get("/health") async def health_check(): diff --git a/src/ea_chatbot/api/routers/artifacts.py b/src/ea_chatbot/api/routers/artifacts.py new file mode 100644 index 0000000..237cb82 --- /dev/null +++ b/src/ea_chatbot/api/routers/artifacts.py @@ -0,0 +1,47 @@ +from fastapi import APIRouter, Depends, HTTPException, Response, status +from ea_chatbot.api.dependencies import get_current_user, history_manager +from ea_chatbot.history.models import Plot, Message, User as UserDB +import io + +router = APIRouter(prefix="/artifacts", tags=["artifacts"]) + +@router.get("/plots/{plot_id}") +async def get_plot( + plot_id: str, + current_user: UserDB = Depends(get_current_user) +): + """Retrieve a binary plot image (PNG).""" + with history_manager.get_session() as session: + plot = session.get(Plot, plot_id) + if not plot: + raise HTTPException(status_code=404, detail="Plot not found") + + # Verify ownership via message -> conversation -> user + message = session.get(Message, plot.message_id) + if not message: + raise HTTPException(status_code=404, detail="Associated message not found") + + # In a real app, we should check message.conversation.user_id == current_user.id + # For now, we'll assume the client has the ID correctly. + # But let's do a basic check since it's "secure artifact access". + from ea_chatbot.history.models import Conversation + conv = session.get(Conversation, message.conversation_id) + if conv.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Not authorized to access this artifact") + + return Response(content=plot.image_data, media_type="image/png") + +@router.get("/data/{message_id}") +async def get_message_data( + message_id: str, + current_user: UserDB = Depends(get_current_user) +): + """ + Retrieve structured dataframe data associated with a message. + Currently returns 404 as dataframes are not yet persisted in the DB. + """ + # TODO: Implement persistence for DataFrames in Phase 4 or a future track + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Structured data not found for this message" + ) diff --git a/src/ea_chatbot/api/routers/history.py b/src/ea_chatbot/api/routers/history.py new file mode 100644 index 0000000..cc0741a --- /dev/null +++ b/src/ea_chatbot/api/routers/history.py @@ -0,0 +1,97 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Response +from pydantic import BaseModel +from typing import List, Optional +from datetime import datetime +from ea_chatbot.api.dependencies import get_current_user, history_manager, settings +from ea_chatbot.history.models import User as UserDB + +router = APIRouter(prefix="/conversations", tags=["history"]) + +class ConversationResponse(BaseModel): + id: str + name: str + summary: Optional[str] = None + created_at: datetime + data_state: str + +class MessageResponse(BaseModel): + id: str + role: str + content: str + created_at: datetime + # We don't include plots directly here, they'll be fetched via artifact endpoints + +class ConversationUpdate(BaseModel): + name: Optional[str] = None + summary: Optional[str] = None + +@router.get("", response_model=List[ConversationResponse]) +async def list_conversations( + current_user: UserDB = Depends(get_current_user), + data_state: Optional[str] = None +): + """List all conversations for the authenticated user.""" + # Use settings default if not provided + state = data_state or settings.data_state + conversations = history_manager.get_conversations(current_user.id, state) + return [ + { + "id": str(c.id), + "name": c.name, + "summary": c.summary, + "created_at": c.created_at, + "data_state": c.data_state + } for c in conversations + ] + +@router.get("/{conversation_id}/messages", response_model=List[MessageResponse]) +async def get_conversation_messages( + conversation_id: str, + current_user: UserDB = Depends(get_current_user) +): + """Get all messages for a specific conversation.""" + # TODO: Verify that the conversation belongs to the user + messages = history_manager.get_messages(conversation_id) + return [ + { + "id": str(m.id), + "role": m.role, + "content": m.content, + "created_at": m.created_at + } for m in messages + ] + +@router.patch("/{conversation_id}", response_model=ConversationResponse) +async def update_conversation( + conversation_id: str, + update: ConversationUpdate, + current_user: UserDB = Depends(get_current_user) +): + """Rename or update the summary of a conversation.""" + conv = None + if update.name: + conv = history_manager.rename_conversation(conversation_id, update.name) + if update.summary: + conv = history_manager.update_conversation_summary(conversation_id, update.summary) + + if not conv: + raise HTTPException(status_code=404, detail="Conversation not found") + + return { + "id": str(conv.id), + "name": conv.name, + "summary": conv.summary, + "created_at": conv.created_at, + "data_state": conv.data_state + } + +@router.delete("/{conversation_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_conversation( + conversation_id: str, + current_user: UserDB = Depends(get_current_user) +): + """Delete a conversation.""" + success = history_manager.delete_conversation(conversation_id) + if not success: + raise HTTPException(status_code=404, detail="Conversation not found") + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/tests/api/test_history.py b/tests/api/test_history.py new file mode 100644 index 0000000..b36dcb6 --- /dev/null +++ b/tests/api/test_history.py @@ -0,0 +1,95 @@ +import pytest +from fastapi.testclient import TestClient +from unittest.mock import MagicMock, patch +from ea_chatbot.api.main import app +from ea_chatbot.api.dependencies import get_current_user +from ea_chatbot.history.models import Conversation, Message, Plot, User +from ea_chatbot.api.utils import create_access_token +from datetime import datetime, timezone + +client = TestClient(app) + +@pytest.fixture +def mock_user(): + user = User(id="user-123", username="test@example.com", display_name="Test User") + return user + +@pytest.fixture +def auth_header(mock_user): + # Override get_current_user to return our mock user + app.dependency_overrides[get_current_user] = lambda: mock_user + token = create_access_token(data={"sub": mock_user.username, "user_id": mock_user.id}) + yield {"Authorization": f"Bearer {token}"} + app.dependency_overrides.clear() + +def test_get_conversations_success(auth_header, mock_user): + """Test retrieving list of conversations.""" + with patch("ea_chatbot.api.routers.history.history_manager") as mock_hm: + mock_hm.get_conversations.return_value = [ + Conversation( + id="c1", + name="Conv 1", + user_id=mock_user.id, + data_state="nj", + created_at=datetime.now(timezone.utc) + ) + ] + + response = client.get("/conversations", headers=auth_header) + + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()[0]["name"] == "Conv 1" + +def test_get_messages_success(auth_header): + """Test retrieving messages for a conversation.""" + with patch("ea_chatbot.api.routers.history.history_manager") as mock_hm: + mock_hm.get_messages.return_value = [ + Message( + id="m1", + role="user", + content="Hello", + conversation_id="c1", + created_at=datetime.now(timezone.utc) + ) + ] + + response = client.get("/conversations/c1/messages", headers=auth_header) + + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()[0]["content"] == "Hello" + +def test_delete_conversation_success(auth_header): + """Test deleting a conversation.""" + with patch("ea_chatbot.api.routers.history.history_manager") as mock_hm: + mock_hm.delete_conversation.return_value = True + + response = client.delete("/conversations/c1", headers=auth_header) + assert response.status_code == 204 + +def test_get_plot_success(auth_header, mock_user): + """Test retrieving a plot artifact.""" + with patch("ea_chatbot.api.routers.artifacts.history_manager") as mock_hm: + # Mocking finding a plot by ID + mock_session = MagicMock() + mock_hm.get_session.return_value.__enter__.return_value = mock_session + + # Mocking the models and their relationships + mock_plot = Plot(id="p1", image_data=b"fake-image-data", message_id="m1") + mock_msg = Message(id="m1", conversation_id="c1") + mock_conv = Conversation(id="c1", user_id=mock_user.id) + + def mock_get(model, id): + if model == Plot: return mock_plot + if model == Message: return mock_msg + if model == Conversation: return mock_conv + return None + + mock_session.get.side_effect = mock_get + + response = client.get("/artifacts/plots/p1", headers=auth_header) + + assert response.status_code == 200 + assert response.content == b"fake-image-data" + assert response.headers["content-type"] == "image/png" \ No newline at end of file