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
102 lines
3.3 KiB
Python
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()
|