Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,6 @@ EMAIL_HOST=
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=

CHAT_BACKEND=ollama


3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,6 @@ dmypy.json
.pyre/
/requirements-dev.txt
/.env.smtp


.idea
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ ENV UV_LINK_MODE=copy \
COPY pyproject.toml /_lock/
COPY uv.lock /_lock/

RUN cd /_lock && uv sync --locked --no-install-project
RUN cd /_lock && uv sync --group dev --group test --locked --no-install-project
##########################################################################
FROM python:3.14.4-slim-trixie

Expand All @@ -33,6 +33,7 @@ WORKDIR /panettone
COPY /app/ app/
COPY /tests/ tests/
COPY /templates/ templates/
COPY /static/ /panettone/static/
COPY .env app/
COPY alembic.ini /panettone/alembic.ini
COPY /alembic/ /panettone/alembic/
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ help: ## Show this help
# ====================================================================================
.PHONY: docker-build
docker-build: ## Build project Docker images using compose
docker compose build
docker compose -f granian-compose.yml build

.PHONY: docker-up
docker-up: ## Run project with compose
Expand Down
131 changes: 131 additions & 0 deletions app/api/chat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""Websocket endpoint implementing the chat flow.

Mirrors the Pydantic AI chat-app interaction pattern: a client connects,
receives a ``connected`` event with its session id, may ``start`` a
conversation (optionally seeding a system prompt), then repeatedly sends
``user_message`` events. Each user message is appended to the session's
message history (role/content shaped like Pydantic AI's message history) and
forwarded to the pluggable :class:`~app.services.chat_agent.ChatAgent`, whose
reply is streamed back as ``assistant_chunk`` events followed by a single
aggregated ``assistant_message``. Clients can also request the full history
(``history_request``) or gracefully close the conversation (``end``).

Example flow (JSON payloads, one per websocket text frame)::

-> (connect)
<- {"type": "connected", "session_id": "..."}
-> {"type": "start", "system_prompt": "You are a helpful assistant."}
<- {"type": "started", "session_id": "..."}
-> {"type": "user_message", "content": "Hello there"}
<- {"type": "assistant_chunk", "index": 0, "content": "Hello "}
<- {"type": "assistant_chunk", "index": 1, "content": "there! "}
<- {"type": "assistant_message", "message": {"role": "assistant", "content": "Hello there! "}}
-> {"type": "history_request"}
<- {"type": "history", "messages": [...]}
-> {"type": "end"}
(connection closed by client/server)
"""

from typing import Any

from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from pydantic import TypeAdapter, ValidationError
from rotoger import get_logger

from app.schemas.chat import (
AssistantChunk,
AssistantMessage,
ChatError,
ChatMessage,
ClientEvent,
Connected,
ConversationStarted,
EndConversation,
HistoryResponse,
RequestHistory,
SendUserMessage,
StartConversation,
)
from app.services.chat_agent import ChatAgent
from app.services.chat_session import ChatSession, ChatSessionManager

logger = get_logger()

router = APIRouter()

_client_event_adapter: TypeAdapter[Any] = TypeAdapter(ClientEvent)


async def _handle_start(
websocket: WebSocket, session: ChatSession, event: StartConversation
) -> None:
session.messages.clear()
if event.system_prompt:
session.add(ChatMessage(role="system", content=event.system_prompt))
await websocket.send_json(
ConversationStarted(session_id=session.id).model_dump(mode="json")
)


async def _handle_user_message(
websocket: WebSocket, session: ChatSession, agent: ChatAgent, event: SendUserMessage
) -> None:
session.add(ChatMessage(role="user", content=event.content))

chunks: list[str] = []
index = 0
async for chunk in agent.stream_reply(session.history()):
chunks.append(chunk)
await websocket.send_json(
AssistantChunk(index=index, content=chunk).model_dump(mode="json")
)
index += 1

assistant_message = ChatMessage(role="assistant", content="".join(chunks))
session.add(assistant_message)
await websocket.send_json(
AssistantMessage(message=assistant_message).model_dump(mode="json")
)


async def _handle_history_request(websocket: WebSocket, session: ChatSession) -> None:
await websocket.send_json(
HistoryResponse(messages=session.history()).model_dump(mode="json")
)


@router.websocket("/ws")
async def chat_websocket(websocket: WebSocket) -> None:
"""Websocket chat endpoint. See module docstring for the event flow."""
await websocket.accept()

agent: ChatAgent = websocket.app.chat_agent
session_manager: ChatSessionManager = ChatSessionManager()
session = await session_manager.create()

await websocket.send_json(Connected(session_id=session.id).model_dump(mode="json"))

try:
while True:
raw = await websocket.receive_json()
try:
event = _client_event_adapter.validate_python(raw)
except ValidationError as exc:
await websocket.send_json(
ChatError(message=f"Invalid event: {exc}").model_dump(mode="json")
)
continue

match event:
case StartConversation():
await _handle_start(websocket, session, event)
case SendUserMessage():
await _handle_user_message(websocket, session, agent, event)
case RequestHistory():
await _handle_history_request(websocket, session)
case EndConversation():
break
except WebSocketDisconnect:
await logger.ainfo("Chat websocket disconnected", session_id=str(session.id))
finally:
await session_manager.remove(session.id)
21 changes: 21 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
from typing import Literal

from pydantic import BaseModel, PostgresDsn, RedisDsn, computed_field
from pydantic_core import MultiHostUrl
Expand All @@ -13,6 +14,25 @@ class SMTPConfig(BaseModel):
template_path: str = os.getenv("EMAIL_TEMPLATE_PATH", "templates")


class ChatConfig(BaseModel):
"""Configuration for the websocket chat service's model-client adapter.

``backend`` selects which :class:`~app.services.chat_agent.ChatAgent`
implementation is built by
:func:`~app.services.chat_agent.build_chat_agent` during app startup:

- ``"stub"``: a local, dependency-free echo agent (default, no network
calls, ideal for local dev/tests).
- ``"ollama"``: streams completions from an OpenAI-compatible endpoint
(e.g. a local Ollama server), reusing the same adapter interface.
"""

backend: Literal["stub", "ollama"] = os.getenv("CHAT_BACKEND", "stub")
base_url: str = os.getenv("CHAT_BASE_URL", "http://localhost:11434/v1")
model: str = os.getenv("CHAT_MODEL", "llama3.2")
stream_delay_seconds: float = float(os.getenv("CHAT_STREAM_DELAY", "0.02"))


class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env", env_ignore_empty=True, extra="ignore"
Expand All @@ -21,6 +41,7 @@ class Settings(BaseSettings):
jwt_expire: int = os.getenv("JWT_EXPIRE")

smtp: SMTPConfig = SMTPConfig()
chat: ChatConfig = ChatConfig()

REDIS_HOST: str
REDIS_PORT: int
Expand Down
29 changes: 20 additions & 9 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
from rotoger import get_logger
from starlette.middleware import Middleware
from starlette.middleware.gzip import GZipMiddleware
from starlette.templating import _TemplateResponse

from app.api.chat import router as chat_ws_router
from app.api.health import router as health_router
from app.api.ml import router as ml_router
from app.api.nonsense import router as nonsense_router
Expand All @@ -20,6 +22,8 @@
from app.middleware.profiler import ProfilingMiddleware
from app.redis import get_redis
from app.services.auth import AuthBearer
from app.services.chat_agent import build_chat_agent
from app.services.chat_session import ChatSessionManager

templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")

Expand All @@ -29,21 +33,27 @@ async def lifespan(app: FastAPI):
app.logger = get_logger()
app.redis = await get_redis()
postgres_dsn = global_settings.postgres_url.unicode_string()
# Chat service: initialize the pluggable model-client adapter. The session
# manager is now a singleton and will be auto-instantiated on first access.
# See app/services/chat_agent.py to swap the local stub for a real model
# client (OpenAI, Ollama, etc.).
app.chat_agent = build_chat_agent(global_settings.chat)
try:
app.postgres_pool = await asyncpg.create_pool(
dsn=postgres_dsn,
min_size=5,
max_size=20,
)
await app.logger.ainfo(
"Postgres pool created", idle_size=app.postgres_pool.get_idle_size()
)
# app.postgres_pool = await asyncpg.create_pool(
# dsn=postgres_dsn,
# min_size=5,
# max_size=20,
# )
# await app.logger.ainfo(
# "Postgres pool created", idle_size=app.postgres_pool.get_idle_size()
# )
yield
except Exception as e:
await app.logger.aerror("Error during app startup", error=repr(e))
raise
finally:
await app.redis.close()
await app.chat_agent.aclose()
# await app.postgres_pool.close()


Expand All @@ -65,6 +75,7 @@ def create_app() -> FastAPI:
app.include_router(shakespeare_router)
app.include_router(user_router)
app.include_router(ml_router, prefix="/v1/ml", tags=["ML"])
app.include_router(chat_ws_router, prefix="/v1/chat", tags=["Chat"])
app.include_router(
health_router, prefix="/v1/public/health", tags=["Health, Public"]
)
Expand All @@ -79,7 +90,7 @@ def create_app() -> FastAPI:
register_exception_handlers(app)

@app.get("/index", response_class=HTMLResponse)
def get_index(request: Request):
def get_index(request: Request) -> _TemplateResponse:
return templates.TemplateResponse("index.html", {"request": request})

return app
Expand Down
2 changes: 1 addition & 1 deletion app/models/stuff.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ class Stuff(Base):

@classmethod
@compile_sql_or_scalar
async def get_by_name(cls, db_session: AsyncSession, name: str, compile_sql=False):
async def get_by_name(cls, db_session: AsyncSession, name: str, compile_sql: bool=False):
stmt = select(cls).options(joinedload(cls.nonsense)).where(cls.name == name)
return stmt

Expand Down
6 changes: 3 additions & 3 deletions app/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@ class User(Base):
_password: bytes = Column(LargeBinary, nullable=False)

@property
def password(self):
def password(self) -> str:
return self._password.decode("utf-8")

@password.setter
def password(self, password: SecretStr):
def password(self, password: SecretStr) -> None:
_password_string = password.get_secret_value().encode("utf-8")
self._password = bcrypt.hashpw(_password_string, bcrypt.gensalt())

def check_password(self, password: SecretStr):
def check_password(self, password: SecretStr) -> bool:
return bcrypt.checkpw(
password.get_secret_value().encode("utf-8"), self._password
)
Expand Down
Loading
Loading