"""Conversation history API.""" from __future__ import annotations import csv import io from typing import Any from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import StreamingResponse from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.db import get_db from app.models import Conversation, Lead, Message from app.schemas import ( ConversationDetail, ConversationSummary, MessageOut, ) from app.services.leads import lead_to_dict router = APIRouter( prefix="/api/conversations", tags=["conversations"] ) @router.get("", response_model=list[ConversationSummary]) async def list_conversations( db: AsyncSession = Depends(get_db), ) -> list[ConversationSummary]: """List conversations newest first.""" msg_count = ( select( Message.conversation_id, func.count(Message.id).label("cnt"), ) .group_by(Message.conversation_id) .subquery() ) result = await db.execute( select(Conversation, Lead, msg_count.c.cnt) .outerjoin(Lead, Lead.conversation_id == Conversation.id) .outerjoin( msg_count, msg_count.c.conversation_id == Conversation.id ) .order_by(Conversation.created_at.desc()) .limit(200) ) rows = result.all() items: list[ConversationSummary] = [] for conv, lead, count in rows: items.append( ConversationSummary( id=conv.id, channel=conv.channel, language=conv.language, status=conv.status, handed_off=conv.handed_off, appointment_booked=conv.appointment_booked, resolved_without_human=conv.resolved_without_human, message_count=int(count or 0), created_at=conv.created_at, lead_name=lead.name if lead else None, ) ) return items @router.get( "/{conversation_id}", response_model=ConversationDetail, ) async def get_conversation( conversation_id: int, db: AsyncSession = Depends(get_db), ) -> ConversationDetail: """Get one conversation with messages.""" result = await db.execute( select(Conversation) .where(Conversation.id == conversation_id) .options( selectinload(Conversation.messages), selectinload(Conversation.lead), selectinload(Conversation.appointment), ) ) conv = result.scalar_one_or_none() if conv is None: raise HTTPException(status_code=404, detail="Not found") appointment: dict[str, Any] | None = None if conv.appointment: appointment = { "slot_start": conv.appointment.slot_start, "slot_end": conv.appointment.slot_end, "status": conv.appointment.status, "attendee_name": conv.appointment.attendee_name, "attendee_email": conv.appointment.attendee_email, } return ConversationDetail( id=conv.id, channel=conv.channel, language=conv.language, status=conv.status, handed_off=conv.handed_off, appointment_booked=conv.appointment_booked, resolved_without_human=conv.resolved_without_human, created_at=conv.created_at, messages=[ MessageOut( id=m.id, role=m.role, content=m.content, language=m.language, grounded=m.grounded, created_at=m.created_at, ) for m in conv.messages ], lead=lead_to_dict(conv.lead), appointment=appointment, ) @router.get("/{conversation_id}/export") async def export_conversation( conversation_id: int, db: AsyncSession = Depends(get_db), ) -> StreamingResponse: """Export conversation messages as CSV.""" result = await db.execute( select(Conversation) .where(Conversation.id == conversation_id) .options(selectinload(Conversation.messages)) ) conv = result.scalar_one_or_none() if conv is None: raise HTTPException(status_code=404, detail="Not found") buffer = io.StringIO() writer = csv.writer(buffer) writer.writerow( ["id", "role", "language", "grounded", "created_at", "content"] ) for m in conv.messages: writer.writerow( [ m.id, m.role, m.language or "", m.grounded if m.grounded is not None else "", m.created_at.isoformat(), m.content, ] ) buffer.seek(0) return StreamingResponse( iter([buffer.getvalue()]), media_type="text/csv", headers={ "Content-Disposition": ( f'attachment; filename="conversation_{conversation_id}.csv"' ) }, )