From 495db8f5f2d1c45f15d86d273704630e14aeb36d Mon Sep 17 00:00:00 2001 From: furyhawk Date: Mon, 15 Jun 2026 09:21:07 +0800 Subject: [PATCH] feat: add documentation for adding new features and update dependency paths --- AGENTS.md | 1 + docs/adding_features.md | 220 ++++++++++++++++++++++++++++++++++++++++ docs/patterns.md | 6 +- 3 files changed, 224 insertions(+), 3 deletions(-) create mode 100644 docs/adding_features.md diff --git a/AGENTS.md b/AGENTS.md index 90c40fe..1843222 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,3 +114,4 @@ Environment variables loaded from `.env` via `pydantic-settings` (`backend/core/ - `docs/architecture.md` - Architecture details - `docs/patterns.md` - Code patterns +- `docs/adding_features.md` - How to add features \ No newline at end of file diff --git a/docs/adding_features.md b/docs/adding_features.md new file mode 100644 index 0000000..5466ddb --- /dev/null +++ b/docs/adding_features.md @@ -0,0 +1,220 @@ +# Adding New Features + +## Adding a New API Endpoint + +This example adds a "Notification" feature end-to-end, following the +repository + service pattern used throughout the codebase. **Routes never +contain direct database calls** — all data access goes through a service, +which delegates to a repository. + +1. **Create schema** in `schemas/` + ```python + # schemas/notification.py + from datetime import datetime + from uuid import UUID + + from pydantic import BaseModel + + + class NotificationCreate(BaseModel): + title: str + body: str + channel: str = "email" + + + class NotificationResponse(BaseModel): + id: UUID + title: str + body: str + channel: str + is_read: bool + created_at: datetime + ``` + +2. **Create model** in `db/models/` + ```python + # db/models/notification.py + from uuid import uuid4 + + from sqlalchemy import Boolean, DateTime, String, func + from sqlalchemy.dialects.postgresql import UUID + from sqlalchemy.orm import Mapped, mapped_column + + from backend.db.base import Base + + + class Notification(Base): + __tablename__ = "notifications" + + id: Mapped[UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid4 + ) + title: Mapped[str] = mapped_column(String(255)) + body: Mapped[str] = mapped_column(String) + channel: Mapped[str] = mapped_column(String(50), default="email") + is_read: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[DateTime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + ``` + + Don't forget to import it in `db/models/__init__.py`. + +3. **Create repository** in `repositories/` + ```python + # repositories/notification.py + from uuid import UUID + + from sqlalchemy import select + from sqlalchemy.ext.asyncio import AsyncSession + + from backend.db.models.notification import Notification + + + class NotificationRepository: + async def create(self, db: AsyncSession, **kwargs) -> Notification: + notification = Notification(**kwargs) + db.add(notification) + await db.flush() + await db.refresh(notification) + return notification + + async def get_by_id(self, db: AsyncSession, notification_id: UUID) -> Notification | None: + return await db.get(Notification, notification_id) + + async def list_unread(self, db: AsyncSession, limit: int = 50) -> list[Notification]: + result = await db.execute( + select(Notification) + .where(Notification.is_read.is_(False)) + .order_by(Notification.created_at.desc()) + .limit(limit) + ) + return list(result.scalars().all()) + ``` + +4. **Create service** in `services/` + ```python + # services/notification.py + from uuid import UUID + + from sqlalchemy.ext.asyncio import AsyncSession + + from backend.core.exceptions import NotFoundError + from backend.repositories.notification import NotificationRepository + from backend.schemas.notification import NotificationCreate + + + class NotificationService: + def __init__(self, db: AsyncSession): + self.db = db + self.repo = NotificationRepository() + + async def create(self, data: NotificationCreate) -> "Notification": + return await self.repo.create(self.db, **data.model_dump()) + + async def get_or_raise(self, notification_id: UUID) -> "Notification": + notification = await self.repo.get_by_id(self.db, notification_id) + if not notification: + raise NotFoundError( + message="Notification not found", + details={"id": str(notification_id)}, + ) + return notification + + async def list_unread(self) -> list["Notification"]: + return await self.repo.list_unread(self.db) + ``` + +5. **Register dependency** in `backend/core/dependencies.py` + ```python + from backend.services.notification import NotificationService + + + def get_notification_service(db: DBSession) -> NotificationService: + """Create NotificationService instance with database session.""" + return NotificationService(db) + + + NotificationSvc = Annotated[NotificationService, Depends(get_notification_service)] + ``` + +6. **Create route** in `api/routes/v1/` + ```python + # api/routes/v1/notifications.py + from fastapi import APIRouter, status + + from backend.api.deps import CurrentUser, NotificationSvc + from backend.schemas.notification import NotificationCreate, NotificationResponse + + router = APIRouter() + + + @router.post("/", response_model=NotificationResponse, status_code=status.HTTP_201_CREATED) + async def create_notification( + data: NotificationCreate, + current_user: CurrentUser, + service: NotificationSvc, + ): + return await service.create(data) + + + @router.get("/", response_model=list[NotificationResponse]) + async def list_unread( + current_user: CurrentUser, + service: NotificationSvc, + ): + return await service.list_unread() + ``` + +7. **Register router** in `api/routes/v1/__init__.py` + ```python + from backend.api.routes.v1 import notifications + + v1_router.include_router( + notifications.router, prefix="/notifications", tags=["notifications"] + ) + ``` + +## Adding a Custom CLI Command + +Commands are auto-discovered from `backend/commands/`. + +```python +# backend/commands/my_command.py +from backend.commands import command, success, error +import click + +@command("my-command", help="Description of what this does") +@click.option("--name", "-n", required=True, help="Name parameter") +def my_command(name: str): + # Your logic here + success(f"Done: {name}") +``` + +Run with: `uv run ai_agent cmd my-command --name test` + +## Adding an AI Agent Tool (PydanticAI) + +```python +# backend/agents/assistant.py +@agent.tool +async def my_tool(ctx: RunContext[Deps], param: str) -> dict: + """Tool description for LLM - be specific about what it does.""" + # Access dependencies via ctx.deps + result = await some_operation(param) + return {"result": result} +``` + +## Adding a Database Migration + +```bash +# Create migration +uv run alembic revision --autogenerate -m "Add notifications table" + +# Apply migration +uv run alembic upgrade head + +# Or use CLI +uv run ai_agent db migrate -m "Add notifications table" +uv run ai_agent db upgrade +``` diff --git a/docs/patterns.md b/docs/patterns.md index 43db2e2..2f16b8f 100644 --- a/docs/patterns.md +++ b/docs/patterns.md @@ -5,7 +5,7 @@ Use FastAPI's `Depends()` for injecting dependencies: ```python -from app.api.deps import get_db, get_current_user +from backend.core.dependencies import get_db, get_current_user @router.get("/conversations") async def list_conversations( @@ -19,7 +19,7 @@ async def list_conversations( > **Important:** Routes never contain direct database calls. All data access > goes through a service, which in turn delegates to a repository. -Available dependencies in `app/api/deps.py`: +Available dependencies in `backend/core/dependencies.py`: - `get_db` - Database session - `get_current_user` - Authenticated user (raises 401 if not authenticated) - `get_current_user_optional` - User or None @@ -84,7 +84,7 @@ class ConversationRepository: Use domain exceptions in services: ```python -from app.core.exceptions import NotFoundError, AlreadyExistsError, ValidationError +from backend.core.exceptions import NotFoundError, AlreadyExistsError, ValidationError # In service if not conversation: