docs: 更新 HTTP 错误码、用户积分、占卜运行及用户资料协议文档

This commit is contained in:
qzl
2026-04-10 16:45:45 +08:00
parent 1bc8bc6a27
commit 17ef460391
78 changed files with 18680 additions and 25 deletions
@@ -0,0 +1,221 @@
# Database Guidelines
> Database patterns and conventions for this project.
---
## Overview
This project uses:
- **SQLAlchemy 2.0** with async support (`asyncpg` driver)
- **Alembic** for schema migrations
- **Supabase Auth** as identity source
- **PostgreSQL** as primary database
- **Soft delete** pattern with `deleted_at` column
---
## Query Patterns
### Base Repository Pattern
All repositories inherit from `BaseRepository` (`core.db.base_repository.BaseRepository`):
```python
from core.db.base_repository import BaseRepository
from models.agent_chat_session import AgentChatSession
class AgentRepository(BaseRepository[AgentChatSession]):
def __init__(self, session: AsyncSession) -> None:
super().__init__(session, AgentChatSession)
```
**Provided methods:**
- `get_by_id(entity_id)` - Get single entity by ID
- `get_one(*filters)` - Get single entity with filters
- `update_by_id(entity_id, update_data)` - Update entity
- `soft_delete_by_id(entity_id)` - Soft delete (sets `deleted_at`)
**Soft delete behavior:**
- Queries **automatically exclude** deleted records
- `deleted_at` is set to `datetime.now(timezone.utc)`
- Repository checks if model has `deleted_at` column before applying filter
### Query Examples
```python
# Get by ID (auto-filters deleted)
session = await repository.get_by_id(session_id)
# Get with custom filters
session = await repository.get_one(
AgentChatSession.owner_id == user_id,
AgentChatSession.id == session_id,
)
# Update entity
updated = await repository.update_by_id(
session_id,
{"title": "New Title", "updated_at": datetime.now(timezone.utc)},
)
```
### Custom Queries
For complex queries, add custom methods to repository:
```python
class AgentRepository(BaseRepository[AgentChatSession]):
async def get_active_sessions_by_user(
self, user_id: UUID
) -> list[AgentChatSession]:
stmt = (
select(self._model)
.where(self._model.owner_id == user_id)
.order_by(self._model.created_at.desc())
)
stmt = self._apply_soft_delete_filter(stmt)
result = await self._session.execute(stmt)
return list(result.scalars().all())
```
---
## Migrations
### Creating Migrations
**Use `dev-migrate.sh` script:**
```bash
# Create new migration
./infra/scripts/dev-migrate.sh revision --autogenerate -m "add user preferences table"
```
**Migration file naming:**
- Format: `YYYYMMDD_####_<description>.py`
- Example: `20260411_0001_initial_llm_schema.py`
**Migration structure:**
```python
def upgrade() -> None:
op.create_table(
"llm_factory",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("name", sa.String(length=50), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
op.create_index("ix_llm_factory_name", "llm_factory", ["name"], unique=True)
_enable_rls("llm_factory") # RLS for Supabase Auth
def downgrade() -> None:
op.drop_index("ix_llm_factory_name", table_name="llm_factory")
op.drop_table("llm_factory")
```
### Running Migrations
**Three modes (via `dev-migrate.sh`):**
```bash
# Migrations only
./infra/scripts/dev-migrate.sh migrate
# Seed data only
./infra/scripts/dev-migrate.sh init-data
# Both (migrate + seed)
./infra/scripts/dev-migrate.sh bootstrap
```
**Alembic is the ONLY source of truth for schema changes.**
---
## Naming Conventions
### Tables
- **snake_case**: `agent_chat_session`, `llm_factory`, `points_ledger`
- **Plural or singular**: Based on domain semantics
- **Timestamps**: `created_at`, `updated_at`, `deleted_at` (always timezone-aware)
### Columns
- **snake_case**: `owner_id`, `model_code`, `request_url`
- **Foreign keys**: `<entity>_id` (e.g., `factory_id`, `owner_id`)
- **Indexes**: `ix_<table>_<column>` (e.g., `ix_llms_model_code`)
### Models
- **PascalCase**: `AgentChatSession`, `LlmFactory`
- **File location**: `models/<entity>.py`
---
## Transactions
### Service Layer Responsibility
**Services own transaction boundaries:**
```python
class AgentService:
async def enqueue_run(self, ...) -> TaskAccepted:
async with self._repository._session.begin(): # Transaction start
# Multiple repository calls
session = await self._repository.get_by_id(session_id)
await self._repository.update_by_id(session_id, {...})
# Transaction commit on successful exit
```
### Repository Layer
**Repositories do NOT manage transactions:**
```python
# Repository uses session, but doesn't commit
result = await self._session.execute(stmt)
await self._session.flush() # Flush to DB, but don't commit
return result.scalar_one_or_none()
```
---
## Common Mistakes
### ❌ Using raw SQL without Alembic
- **Wrong**: Creating tables directly in code
- **Right**: All DDL changes via Alembic migrations
### ❌ Hardcoding database credentials
- **Wrong**: `password = "postgres123"`
- **Right**: Use `core.config.settings.Settings.database.password`
### ❌ Bypassing soft delete filter
- **Wrong**: `select(Model).where(Model.id == x)` (includes deleted)
- **Right**: Use `BaseRepository` methods or apply `self._apply_soft_delete_filter()`
### ❌ Using `session.commit()` in repository
- **Wrong**: Repository calling `await session.commit()`
- **Right**: Let service layer manage transactions with `session.begin()`
### ❌ Not using timezone-aware timestamps
- **Wrong**: `datetime.now()`
- **Right**: `datetime.now(timezone.utc)`
### ❌ Getting `owner_id` from client payload
- **Wrong**: `owner_id = payload.owner_id`
- **Right**: `owner_id = current_user.id` (from verified JWT sub claim)
---
## Database Access Rules
**From AGENTS.md:**
1. Use `supabase mcp` tools (`supabase_execute_sql`, `supabase_list_tables`) to **view** data
2. Use **Alembic migrations** to **modify** schema
3. Use **service-role DB access only** in backend
4. Soft delete uses `deleted_at`; reads exclude deleted by default
@@ -0,0 +1,145 @@
# Directory Structure
> How backend code is organized in this project.
---
## Overview
This project follows a **layered architecture** with clear separation of concerns:
- **Schema → Repository → Service** layering pattern
- Router layer for HTTP endpoints
- Core infrastructure modules
- Domain schemas for business models
---
## Directory Layout
```
backend/
├── src/
│ ├── core/ # Infrastructure and cross-cutting concerns
│ │ ├── agentscope/ # Agent runtime framework
│ │ ├── auth/ # Authentication models and dependencies
│ │ ├── config/ # Settings and configuration
│ │ ├── db/ # Database base classes and session
│ │ ├── http/ # HTTP utilities (errors, middleware)
│ │ └── logging/ # Structured logging setup
│ ├── models/ # SQLAlchemy ORM models
│ ├── schemas/ # Business data models (Pydantic/dataclass)
│ │ ├── agent/ # Agent-related schemas
│ │ ├── domain/ # Domain objects
│ │ └── ...
│ ├── services/ # Shared infrastructure services
│ │ ├── base/ # Base service interfaces
│ │ ├── caches/ # Cache implementations
│ │ └── llm_pricing/ # LLM pricing service
│ └── v1/ # API v1 versioned modules
│ ├── agent/ # Agent module
│ ├── auth/ # Authentication module
│ ├── memories/ # Memories module
│ ├── points/ # Points module
│ └── ... # Other modules
├── tests/ # Test suites
├── alembic/ # Database migrations
│ └── versions/ # Migration files
└── pyproject.toml # Project configuration
```
---
## Module Organization
Each module under `v1/` follows consistent structure:
```
v1/<module>/
├── __init__.py # Module exports
├── router.py # FastAPI router (HTTP endpoints)
├── schemas.py # Request/response schemas (Pydantic)
├── repository.py # Data access layer (CRUD + queries)
├── service.py # Business logic layer (authz + transactions)
├── dependencies.py # FastAPI dependencies (DI)
└── utils.py # Module utilities (optional)
```
**Layering rules:**
- **Router** → handles HTTP, calls service, returns response
- **Service** → business logic, authz, transaction boundary, raises domain errors
- **Repository** → CRUD + query composition only, no auth decisions
**Example:** `v1/agent/` module:
- `router.py` defines `/agent` endpoints
- `service.py` enforces authz (`ensure_session_owner`) and coordinates repositories
- `repository.py` handles database queries for agent sessions
---
## Naming Conventions
### Files
- **snake_case**: Python files (e.g., `agent_service.py`)
- **Module directories**: Singular or plural based on domain (e.g., `agent/`, `memories/`)
### Classes
- **PascalCase**: Classes (`AgentService`, `AgentRepository`)
- **Suffixes**:
- `*Service` - Business logic layer
- `*Repository` - Data access layer
- `*Model` / `*Schema` - Data models (Pydantic)
- `*Settings` - Configuration classes
### Functions/Variables
- **snake_case**: Functions and variables (`get_by_id`, `soft_delete_by_id`)
- **Private prefix**: `_` for internal methods (`_apply_soft_delete_filter`)
### Database Tables
- **snake_case**: Table names (`agent_chat_session`, `llm_factory`)
- **Timestamps**: `created_at`, `updated_at`, `deleted_at` (soft delete)
- **Foreign keys**: `<entity>_id` (e.g., `factory_id`, `owner_id`)
---
## Examples
### Well-organized module: `v1/agent/`
```
v1/agent/
├── router.py # HTTP endpoints, dependencies injection
├── service.py # Business logic, authz checks (ensure_session_owner)
├── repository.py # Database queries
├── schemas.py # Request/response DTOs
├── dependencies.py # Service instantiation (DI)
├── utils.py # Helper functions
└── system_agents_config.py # Module config
```
### Core infrastructure: `core/logging/`
```
core/logging/
├── logger.py # get_logger() interface
├── config.py # Logging configuration
├── formatters.py # Structured log formatters
├── handlers.py # Log handlers
├── filters.py # Sensitive field filters
└── middleware.py # Request logging middleware
```
### ORM models: `models/`
```python
# models/agent_chat_session.py
class AgentChatSession(Base):
__tablename__ = "agent_chat_session"
id: Mapped[uuid.UUID] = ...
owner_id: Mapped[uuid.UUID] = ...
```
### Domain schemas: `schemas/domain/`
```python
# schemas/domain/chat_message.py
class AgentChatMessageMetadata(BaseModel):
role: str
content: str
# ... business fields
```
+293
View File
@@ -0,0 +1,293 @@
# Error Handling
> How errors are handled in this project.
---
## Overview
This project follows **RFC 7807 Problem Details** for HTTP API errors:
- **Standard format**: `application/problem+json`
- **Error codes**: Machine-readable `UPPER_SNAKE_CASE`
- **Source of truth**: `docs/protocols/common/http-error-codes.md`
- **Exception class**: `ApiProblemError` for domain errors
- **Layered approach**: Routers handle HTTP, Services raise domain errors
---
## Error Types
### `ApiProblemError`
**Custom exception for business logic errors:**
```python
from core.http.errors import ApiProblemError, problem_payload
# Raise in service/repository/dependencies
raise ApiProblemError(
status_code=403,
detail=problem_payload(code="AGENT_FORBIDDEN", detail="Forbidden"),
)
# With params
raise ApiProblemError(
status_code=422,
detail=problem_payload(
code="INVALID_RUNTIME_MODE",
detail="Invalid runtime mode",
params={"mode": mode, "valid_modes": ["standard", "divination"]}
),
)
```
**Properties:**
- `status_code`: HTTP status (int)
- `code`: Machine-readable error code (str, `UPPER_SNAKE_CASE`)
- `detail`: Human-readable message (str)
- `params`: Optional additional context (dict)
### `HTTPException` (FastAPI)
**Use ONLY in router layer for HTTP transport errors:**
```python
from fastapi import HTTPException, status
# Only in routers, for simple HTTP errors
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Resource not found"
)
```
**For business logic → use `ApiProblemError` instead.**
---
## Error Handling Patterns
### Service Layer (Business Logic)
**Services should raise `ApiProblemError`:**
```python
class AgentService:
async def enqueue_run(self, *, run_input: RunAgentInput, current_user: CurrentUser) -> TaskAccepted:
# Validate business rules
if owner_id != str(current_user.id):
raise ApiProblemError(
status_code=403,
detail=problem_payload(
code="AGENT_FORBIDDEN",
detail="Forbidden"
),
)
# Return success result
return TaskAccepted(run_id=run_id, thread_id=thread_id)
```
### Repository Layer (Data Access)
**Re-raise exceptions as `ApiProblemError` when needed:**
```python
from sqlalchemy.exc import IntegrityError
from core.http.errors import ApiProblemError, problem_payload
class AgentRepository(BaseRepository[AgentChatSession]):
async def create_session(self, ...) -> AgentChatSession:
try:
self._session.add(session)
await self._session.flush()
return session
except IntegrityError as exc:
raise ApiProblemError(
status_code=409,
detail=problem_payload(
code="DUPLICATE_SESSION",
detail="Session already exists"
),
) from exc
```
### Router Layer (HTTP Endpoints)
**Routers handle `ApiProblemError` and convert to HTTP response:**
```python
from fastapi import APIRouter, HTTPException
from core.http.errors import ApiProblemError
router = APIRouter()
@router.post("/agent/run")
async def create_run(
response: Response,
current_user: CurrentUser = Depends(get_current_user),
service: AgentService = Depends(get_agent_service),
):
try:
result = await service.enqueue_run(run_input=input, current_user=current_user)
return result
except ApiProblemError as exc:
response.status_code = exc.status_code
return {
"code": exc.code,
"detail": exc.detail,
**({"params": exc.params} if exc.params else {}),
}
```
---
## API Error Responses
### RFC 7807 Format
**All API errors return `application/problem+json`:**
```json
{
"code": "AGENT_FORBIDDEN",
"detail": "Forbidden",
"params": {
"resource": "agent_session",
"action": "update"
}
}
```
**Error code registry:**
- **Single source of truth**: `docs/protocols/common/http-error-codes.md`
- **Update requirement**: Any create/modify/deprecate of codes requires doc update
### Error Response Construction
**Use `problem_payload()` helper:**
```python
from core.http.errors import problem_payload
# Simple error
detail = problem_payload(
code="INVALID_INPUT",
detail="Invalid input data"
)
# With params
detail = problem_payload(
code="VALIDATION_ERROR",
detail="Validation failed",
params={"field": "email", "reason": "invalid format"}
)
# Pass to ApiProblemError
raise ApiProblemError(status_code=422, detail=detail)
```
---
## Common Mistakes
### ❌ Catching and ignoring exceptions
```python
# WRONG: Silent failure destroys debuggability
try:
await service.do_something()
except Exception:
pass # Never do this
```
**Right way:** Re-raise or convert to typed error:
```python
try:
await service.do_something()
except SomeSpecificError as exc:
raise ApiProblemError(
status_code=400,
detail=problem_payload(code="OPERATION_FAILED", detail=str(exc)),
) from exc
```
### ❌ Using `print()` for errors
```python
# WRONG: Use logging
except Exception as e:
print(f"Error: {e}") # Never use print in runtime code
```
**Right way:** Use `core.logging`:
```python
from core.logging import get_logger
logger = get_logger(__name__)
try:
await service.do_something()
except Exception as e:
logger.error("operation_failed", error=str(e), exc_info=True)
raise
```
### ❌ Using free-text `detail` as only error contract
```python
# WRONG: No machine-readable code
raise HTTPException(status_code=400, detail="Something went wrong")
```
**Right way:** Use `code` field:
```python
raise ApiProblemError(
status_code=400,
detail=problem_payload(code="OPERATION_FAILED", detail="Something went wrong"),
)
```
### ❌ Using `HTTPException` in service/repository layer
```python
# WRONG: HTTP-specific error in business logic
class AgentService:
async def enqueue_run(self, ...):
raise HTTPException(status_code=403, detail="Forbidden") # Coupled to HTTP
```
**Right way:** Use domain error:
```python
class AgentService:
async def enqueue_run(self, ...):
raise ApiProblemError(
status_code=403,
detail=problem_payload(code="AGENT_FORBIDDEN", detail="Forbidden"),
)
```
### ❌ Not logging exceptions
```python
# WRONG: Exception is caught but not logged
try:
await service.do_something()
except Exception:
raise ApiProblemError(...) # Where did it come from?
```
**Right way:** Log before re-raising:
```python
try:
await service.do_something()
except Exception as exc:
logger.error("operation_failed", error=str(exc), exc_info=True)
raise ApiProblemError(...) from exc
```
+38
View File
@@ -0,0 +1,38 @@
# Backend Development Guidelines
> Best practices for backend development in this project.
---
## Overview
This directory contains guidelines for backend development. Fill in each file with your project's specific conventions.
---
## Guidelines Index
| Guide | Description | Status |
|-------|-------------|--------|
| [Directory Structure](./directory-structure.md) | Module organization and file layout | Filled |
| [Database Guidelines](./database-guidelines.md) | ORM patterns, queries, migrations | Filled |
| [Error Handling](./error-handling.md) | Error types, handling strategies | Filled |
| [Quality Guidelines](./quality-guidelines.md) | Code standards, forbidden patterns | Filled |
| [Logging Guidelines](./logging-guidelines.md) | Structured logging, log levels | Filled |
---
## How to Fill These Guidelines
For each guideline file:
1. Document your project's **actual conventions** (not ideals)
2. Include **code examples** from your codebase
3. List **forbidden patterns** and why
4. Add **common mistakes** your team has made
The goal is to help AI assistants and new team members understand how YOUR project works.
---
**Language**: All documentation should be written in **English**.
+340
View File
@@ -0,0 +1,340 @@
# Logging Guidelines
> How logging is done in this project.
---
## Overview
This project uses **structlog** for structured, JSON-formatted logging:
- **Library**: `structlog` with standard library integration
- **Interface**: `get_logger(name)` from `core.logging`
- **Format**: JSON logs in production, readable logs in development
- **Sensitive fields**: Automatically redacted (passwords, tokens, etc.)
---
## Log Levels
Use appropriate log levels based on event importance:
| Level | When to Use | Examples | Noise Level |
|-------|-------------|----------|-------------|
| **ERROR** | All exceptions and failures | Database connection failed, unhandled exception | Required, never skip |
| **WARNING** | Degraded behavior, retry, fallback | Cache miss, using fallback data, retry attempt | Minimal, only when action taken |
| **INFO** | Key business events | User login, message sent, run started | Minimal, only milestones |
| **DEBUG** | Detailed flow tracing (dev only) | Function entry/exit, variable values | High, avoid in release |
---
## Structured Logging
### Logger Setup
```python
from core.logging import get_logger
logger = get_logger("v1.agent.service")
```
**Module naming convention:**
- Feature modules: `v1.<feature>.<component>` (e.g., `v1.agent.service`)
- Core modules: `core.<module>.<submodule>` (e.g., `core.logging.middleware`)
### Log Format
**JSON structure:**
```json
{
"event": "user_logged_in",
"user_id": "123e4567-e89b-12d3-a456-426614174000",
"timestamp": "2026-04-10T12:34:56.789Z",
"level": "info",
"logger": "v1.agent.service",
"process": 12345,
"thread": 140123456789024
}
```
### Logging Methods
```python
# Simple event
logger.info("user_logged_in", user_id=user.id)
# With extra context
logger.info(
"run_enqueued",
run_id=run_id,
thread_id=thread_id,
user_id=current_user.id,
)
# Error with exception info
try:
await repository.create_session(...)
except IntegrityError as exc:
logger.error(
"session_creation_failed",
error=str(exc),
exc_info=True, # Include stack trace
user_id=user_id,
)
raise
```
---
## What to Log
### Always Log
1. **Exceptions**: Every catch block that handles an exception must log it
2. **Business milestones**: Key domain events (login, run started, message sent)
3. **Failed operations**: Any operation that fails or requires retry
4. **Auth events**: Login/logout, token refresh, auth failures
5. **External calls**: LLM API calls, database queries (at DEBUG level)
### Log with Context
```python
# Include relevant identifiers
logger.info(
"run_completed",
run_id=run_id,
thread_id=thread_id,
duration_seconds=duration,
)
# Include error details
logger.error(
"database_error",
operation="create_session",
error=str(exc),
exc_info=True,
)
```
### Log Levels by Event Type
**INFO - Business Milestones:**
```python
logger.info("user_logged_in", user_id=user.id)
logger.info("run_started", run_id=run_id, thread_id=thread_id)
logger.info("run_completed", run_id=run_id, duration_seconds=duration)
logger.info("message_sent", message_id=msg_id, conversation_id=conv_id)
```
**WARNING - Degraded Behavior:**
```python
logger.warning("cache_miss", key=cache_key, fallback="reload_from_db")
logger.warning("retry_attempt", operation="llm_call", attempt=2, max_attempts=3)
logger.warning("fallback_data_used", reason="external_api_timeout")
```
**ERROR - Failures:**
```python
logger.error("database_connection_failed", error=str(exc), exc_info=True)
logger.error("validation_failed", field="email", reason="invalid_format")
logger.error("auth_failed", user_id=user_id, reason="invalid_token")
try:
await service.do_operation()
except Exception as exc:
logger.error("operation_failed", error=str(exc), exc_info=True)
raise
```
---
## What NOT to Log
### Forbidden: Sensitive Data
**Never log:**
- Passwords (even hashed)
- API keys (`ERYAO__*` environment variables)
- JWT tokens
- Access tokens / refresh tokens
- Session IDs (except for tracing)
- PII (personally identifiable information) - names, emails, phone numbers
- Credit card numbers
- Social security numbers
- Secret keys
- Authorization headers
- Cookies
### Forbidden: Debug in Production
```python
# WRONG: Debug logs in release builds
import os
if os.getenv("DEBUG"):
logger.debug("variable_value", var=some_data) # Never do this
```
**Right way:** Use runtime log level configuration:
```python
# Config handles log level based on environment
# runtime:
# log_level: "INFO" # production
# log_level: "DEBUG" # development
logger.debug("variable_value", var=some_data) # Will be filtered in production
```
### Forbidden: Iteration Logs
```python
# WRONG: Log every iteration
for item in items:
logger.debug("processing_item", item_id=item.id) # Too noisy
process(item)
```
**Right way:** Log only failures:
```python
for item in items:
try:
process(item)
except Exception as exc:
logger.error("item_processing_failed", item_id=item.id, error=str(exc))
```
---
## Configuration
### Settings
```python
# core.config.settings.RuntimeSettings
class RuntimeSettings(BaseModel):
log_level: str = "INFO" # DEBUG, INFO, WARNING, ERROR
log_json: bool = True # JSON format (production) vs readable (dev)
log_rotation: Literal["time", "size", "none"] = "time"
log_rotation_when: str = "midnight" # Daily rotation
log_rotation_interval: int = 1
log_rotation_backup_count: int = 14 # Keep 14 days
log_file_name: str = "" # Auto-generated: "{service_name}.log"
log_sensitive_fields: list[str] = [
"password", "secret", "token", "api_key",
"authorization", "cookie", "client_ip", "user_id",
]
```
### Log Files
**Location:** `logs/{service_name}.log` and `logs/errors/{service_name}.error.log`
**Example:**
- `logs/app.log` - All logs
- `logs/errors/app.error.log` - Errors only
---
## Common Mistakes
### ❌ Using `print()` instead of logging
```python
# WRONG: Never use print in runtime code
print(f"User {user_id} logged in")
```
**Right:** Use `logger.info`:
```python
logger.info("user_logged_in", user_id=user_id)
```
### ❌ Logging sensitive data
```python
# WRONG: Logging password
logger.info("user_login", username=username, password=password)
```
**Right:** Exclude sensitive fields:
```python
logger.info("user_login", username=username) # Password excluded
```
### ❌ Skipping error logging
```python
# WRONG: Exception is suppressed
try:
await service.do_something()
except Exception:
pass # No logging, no re-raise
```
**Right:** Log and re-raise:
```python
try:
await service.do_something()
except Exception as exc:
logger.error("operation_failed", error=str(exc), exc_info=True)
raise
```
### ❌ Info flooding
```python
# WRONG: Log every database query
results = await repository.get_all()
logger.info("query_completed", count=len(results)) # Too noisy
```
**Right:** Log only significant operations:
```python
results = await repository.get_all()
# Only log if something notable happens
if len(results) > MAX_THRESHOLD:
logger.warning("high_result_count", count=len(results))
```
---
## Examples from Codebase
### Service Layer Logging
```python
# v1/agent/service.py
logger = get_logger(__name__)
async def enqueue_run(self, ...) -> TaskAccepted:
logger.info(
"run_enqueued",
run_id=run_id,
thread_id=thread_id,
user_id=current_user.id,
)
# ...
```
### Router Layer Logging
```python
# v1/agent/router.py
logger = get_logger("v1.agent.router")
async def _acquire_sse_slot(*, user_id: str) -> bool:
try:
redis = await get_or_init_redis_client()
# ...
except Exception as exc: # noqa: BLE001
logger.warning(
"sse_slot_acquire_failed",
user_id=user_id,
reason=str(exc),
)
return True # Graceful degradation
```
+396
View File
@@ -0,0 +1,396 @@
# Quality Guidelines
> Code quality standards for backend development.
---
## Overview
This project enforces quality through:
- **Linting**: `ruff` (fast Python linter)
- **Type checking**: `basedpyright` (strict type checker)
- **Formatting**: Consistent code style via linter rules
- **Testing**: `pytest` with async support (`pytest-asyncio`)
- **Pre-commit hooks**: Automated checks before commits
---
## Forbidden Patterns
### ❌ Bypassing Lint/Type Gates
```python
# WRONG: Suppress linter warnings
result = some_function() # noqa: BLE001, reportImplicitRelativeImport
```
**Right way:** Fix the underlying issue:
```python
# Fix exception handling
except Exception as exc:
logger.error("operation_failed", error=str(exc))
raise
```
### ❌ Using `print()` in Runtime Code
```python
# WRONG: Never use print in production code
print(f"Processing user {user_id}")
```
**Right way:** Use `core.logging`:
```python
from core.logging import get_logger
logger = get_logger(__name__)
logger.info("processing_user", user_id=user_id)
```
### ❌ Hardcoding Secrets
```python
# WRONG: Hardcode credentials
api_key = "sk-abcd1234..."
password = "postgres123"
```
**Right way:** Use `core.config.settings`:
```python
from core.config.settings import config
api_key = config.llm.provider_keys["openai"]
password = config.database.password
```
### ❌ Manual Environment Parsing
```python
# WRONG: Direct os.getenv
import os
db_host = os.getenv("DATABASE_HOST", "localhost")
```
**Right way:** Use `Settings`:
```python
from core.config.settings import config
db_host = config.database.host
```
### ❌ Silent Exception Handling
```python
# WRONG: Swallow exception
try:
await service.do_something()
except Exception:
pass # Destroys debuggability
```
**Right way:** Log and propagate:
```python
try:
await service.do_something()
except Exception as exc:
logger.error("operation_failed", error=str(exc), exc_info=True)
raise
```
---
## Required Patterns
### ✅ Strong Typing at Boundaries
```python
from pydantic import BaseModel
# Request/response schemas must use Pydantic
class CreateSessionRequest(BaseModel):
thread_id: str
messages: list[Message]
class SessionResponse(BaseModel):
id: UUID
owner_id: UUID
created_at: datetime
```
### ✅ Schema-First for Data Contracts
```python
# Define schema first, then implement
# v1/agent/schemas.py
class TaskAccepted(BaseModel):
run_id: str
thread_id: str
accepted_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
# Then use in router
@router.post("/run", response_model=TaskAccepted)
async def create_run(...):
return TaskAccepted(run_id=run_id, thread_id=thread_id)
```
### ✅ Dependency Injection via FastAPI
```python
# v1/agent/dependencies.py
from fastapi import Depends
from core.db.base_repository import BaseRepository
async def get_agent_service(
session: AsyncSession = Depends(get_session),
) -> AgentService:
repository = AgentRepository(session)
return AgentService(repository=repository, ...)
# v1/agent/router.py
@router.post("/run")
async def create_run(
service: AgentService = Depends(get_agent_service),
):
return await service.enqueue_run(...)
```
### ✅ Configuration via Settings
```python
# All config through Settings
from core.config.settings import config
class SomeService:
def __init__(self):
self.redis_url = config.redis.url
self.db_url = config.database.url
self.log_level = config.runtime.log_level
```
### ✅ Logging with Context
```python
# Always include relevant context
logger.info(
"run_completed",
run_id=run_id,
thread_id=thread_id,
duration_seconds=duration,
user_id=current_user.id,
)
```
---
## Testing Requirements
### Test Organization
```
backend/tests/
├── unit/ # Unit tests (no external dependencies)
├── integration/ # Integration tests (DB, Redis, HTTP)
├── conftest.py # Shared fixtures
└── ...
```
### Test Requirements
**From AGENTS.md:**
1. **TDD when practical**: Write tests before/alongside implementation
2. **Regression tests**: Changed logic/contracts must have regression tests
3. **Real DB tests**: Use `settings.test.*` credentials (never hardcode)
4. **Integration tests**: Start backend via `./infra/scripts/app.sh` before running
5. **Restart for integration**: Use `./infra/scripts/app.sh restart` before `uv run pytest`
### Test Configuration
```python
# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["backend/tests"]
addopts = "-q --import-mode=importlib"
asyncio_mode = "auto"
pythonpath = ["backend/src"]
```
### Example Test
```python
import pytest
from httpx import AsyncClient
from core.config.settings import Settings
@pytest.mark.asyncio
async def test_create_session(settings: Settings):
# Use settings.test.* for real DB tests
assert settings.test.phone != ""
async with AsyncClient() as client:
response = await client.post(
f"{settings.supabase.url}/api/v1/agent/run",
json={"thread_id": "test-thread"},
)
assert response.status_code == 200
```
---
## Code Review Checklist
### Architecture
- [ ] Follows `schema -> repository -> service` layering
- [ ] Router handles HTTP only, service handles business logic
- [ ] Repository does not make auth decisions
- [ ] Transaction boundary is in service layer, not repository
### Security
- [ ] `owner_id` comes from `current_user.id` (JWT sub claim), not client payload
- [ ] No hardcoded secrets/passwords/tokens
- [ ] Sensitive fields are not logged (passwords, tokens, PII)
- [ ] Auth checks use `ApiProblemError` with proper error codes
### Data
- [ ] Database changes use Alembic migrations
- [ ] Soft delete uses `deleted_at`, queries filter deleted records
- [ ] Timezone-aware timestamps: `datetime.now(timezone.utc)`
- [ ] Foreign keys follow `<entity>_id` naming
### Error Handling
- [ ] All exceptions are logged before re-raising
- [ ] Business logic uses `ApiProblemError`, not `HTTPException`
- [ ] Error responses use RFC 7807 format with `code` field
- [ ] New error codes documented in `docs/protocols/common/http-error-codes.md`
### Logging
- [ ] Uses `core.logging.get_logger`, not `print()`
- [ ] Log level appropriate for event (ERROR for failures, INFO for milestones)
- [ ] Context includes relevant identifiers (run_id, user_id, thread_id)
- [ ] No sensitive data in logs
### Testing
- [ ] Unit tests for service logic
- [ ] Integration tests for changed contracts
- [ ] Test credentials via `settings.test.*`
- [ ] Regression tests for bug fixes
### Code Style
- [ ] Passes `ruff` linting
- [ ] Passes `basedpyright` type checking
- [ ] Follows naming conventions (snake_case for functions/variables)
- [ ] No broad `except Exception:` without logging/re-raising
---
## Linting & Type Checking
### Ruff (Linter)
**Run:**
```bash
uv run ruff check backend/src/
```
**Common fixes:**
- Remove unused imports
- Fix line length (max 100 chars)
- Use `async def` for async functions
- Add type annotations
### Basedpyright (Type Checker)
**Run:**
```bash
uv run basedpyright backend/src/
```
**Common fixes:**
- Add type hints for function parameters
- Use `Optional[T]` for optional values
- Fix `reportImplicitRelativeImport` warnings
- Handle `None` cases explicitly
### Pre-commit Hooks
**Setup:**
```bash
uv run pre-commit install
```
**Runs automatically on:**
- `ruff check`
- `basedpyright`
- Formatting checks
---
## Common Mistakes
### ❌ Missing Type Annotations
```python
# WRONG: No type hints
def get_user(user_id):
return repository.get_by_id(user_id)
```
**Right:**
```python
async def get_user(user_id: UUID) -> User | None:
return await repository.get_by_id(user_id)
```
### ❌ Catch-All Exception Handling
```python
# WRONG: Catching all exceptions without logging
except Exception:
return None
```
**Right:**
```python
except SomeSpecificError as exc:
logger.error("operation_failed", error=str(exc), exc_info=True)
raise ApiProblemError(...) from exc
```
### ❌ Timezone-Naive Datetimes
```python
# WRONG: No timezone
created_at = datetime.now()
```
**Right:**
```python
created_at = datetime.now(timezone.utc)
```
### ❌ Testing with Hardcoded Credentials
```python
# WRONG: Hardcoded test DB
DATABASE_URL = "postgres://user:pass@localhost:5432/test"
```
**Right:**
```python
from core.config.settings import config
# Use settings.test.*
assert config.database.host is not None
```