feat(api): Implement history and artifacts routers
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from ea_chatbot.api.routers import auth
|
from ea_chatbot.api.routers import auth, history, artifacts
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Election Analytics Chatbot API",
|
title="Election Analytics Chatbot API",
|
||||||
@@ -18,6 +18,8 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
|
app.include_router(history.router)
|
||||||
|
app.include_router(artifacts.router)
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
|
|||||||
47
src/ea_chatbot/api/routers/artifacts.py
Normal file
47
src/ea_chatbot/api/routers/artifacts.py
Normal file
@@ -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"
|
||||||
|
)
|
||||||
97
src/ea_chatbot/api/routers/history.py
Normal file
97
src/ea_chatbot/api/routers/history.py
Normal file
@@ -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)
|
||||||
95
tests/api/test_history.py
Normal file
95
tests/api/test_history.py
Normal file
@@ -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"
|
||||||
Reference in New Issue
Block a user