Files
pablan/backend/app/api/conversations/crud.py
T
ProfessorNovaandClaude Opus 5 784b76baf7 Pablan, as it stands
Self-hosted knowledge management for SMEs: a split-screen Markdown editor
whose sections an LLM refines while you write, and RAG question answering
over the documents that result. FastAPI + Postgres/pgvector on the back,
SvelteKit on the front, everything OpenAI-compatible and self-hostable.

Squashed into a single commit; the development history stays local.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CA43ZJda8Rbp2hKXNy8f6b
2026-09-04 09:21:37 +02:00

102 lines
3.3 KiB
Python

"""Starting, listing, reading and deleting conversations.
Everything here is owner-scoped. Deleting is a GDPR surface, not a convenience:
a user must be able to remove their own transcripts, and the messages go with
them by cascade.
"""
import uuid
from typing import Annotated
from fastapi import Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.conversations.access import own_conversation
from app.api.conversations.routing import conversations_router
from app.api.conversations.schemas import (
ConversationCreate,
ConversationDetail,
ConversationSummary,
)
from app.api.conversations.view import message_out, title
from app.auth.deps import get_current_user
from app.db import get_db
from app.errors import ApiError
from app.models import Conversation, Message, User
from app.modes import get_mode
router = conversations_router()
@router.post("")
async def create_conversation(
body: ConversationCreate,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> ConversationSummary:
if get_mode(body.mode.value) is None:
raise ApiError(
400, f"Mode '{body.mode.value}' is not available.", "unknown_mode"
)
conversation = Conversation(mode=body.mode, user_id=user.id)
db.add(conversation)
await db.commit()
return ConversationSummary.model_validate(conversation)
@router.get("")
async def list_conversations(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> list[ConversationSummary]:
"""The sidebar list, newest activity first. The title is the first message,
fetched as a correlated subquery so one statement answers the whole list."""
first_message = (
select(Message.content)
.where(Message.conversation_id == Conversation.id)
.order_by(Message.created_at)
.limit(1)
.correlate(Conversation)
.scalar_subquery()
)
rows = await db.execute(
select(Conversation, first_message)
.where(Conversation.user_id == user.id)
.order_by(Conversation.updated_at.desc())
)
return [
ConversationSummary.model_validate(conversation).model_copy(
update={"title": title(first)}
)
for conversation, first in rows
]
@router.get("/{conversation_id}")
async def get_conversation(
conversation_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> ConversationDetail:
conversation = await own_conversation(db, conversation_id, user, with_messages=True)
first = conversation.messages[0].content if conversation.messages else None
return ConversationDetail.model_validate(conversation).model_copy(
update={
"title": title(first),
"messages": [message_out(message) for message in conversation.messages],
}
)
@router.delete("/{conversation_id}", status_code=204)
async def delete_conversation(
conversation_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> None:
"""GDPR: users delete their own conversations; messages cascade."""
conversation = await own_conversation(db, conversation_id, user)
await db.delete(conversation)
await db.commit()