mirror of
https://github.com/furyhawk/agent_delta.git
synced 2026-07-20 09:45:36 +00:00
init
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
---
|
||||
description: Scaffold a new API endpoint with full layering
|
||||
---
|
||||
|
||||
Create a new API endpoint: $ARGUMENTS
|
||||
|
||||
Follow the project's layered architecture. Create files in this order:
|
||||
|
||||
1. **Schema** (`backend/app/schemas/<entity>.py`):
|
||||
- Inherit `BaseSchema` (and `TimestampSchema` for Read)
|
||||
- Create `*Create`, `*Update`, `*Read`, `*List` models
|
||||
- Use `Field()` with constraints, `EmailStr` where applicable
|
||||
|
||||
2. **DB Model** (`backend/app/db/models/<entity>.py`):
|
||||
- Inherit `Base, TimestampMixin`
|
||||
- Use `Mapped[type]` + `mapped_column()`
|
||||
- Add `__repr__`, relationships with `cascade="all, delete-orphan"`
|
||||
|
||||
3. **Repository** (`backend/app/repositories/<entity>_repo.py`):
|
||||
- Stateless async functions: `get_by_id`, `get_multi`, `create`, `update`, `delete`
|
||||
- Use `db.flush()` + `db.refresh()`, keyword-only args after `db`
|
||||
|
||||
4. **Service** (`backend/app/services/<entity>.py`):
|
||||
- Class with `__init__(self, db: AsyncSession)`
|
||||
- Raise `NotFoundError`, `AlreadyExistsError` as appropriate
|
||||
|
||||
5. **DI** (`backend/app/api/deps.py`):
|
||||
- Add factory function and `Annotated` alias: `EntitySvc = Annotated[EntityService, Depends(get_entity_service)]`
|
||||
|
||||
6. **Route** (`backend/app/api/routes/v1/<entity>.py`):
|
||||
- CRUD: GET list, GET by id, POST (201), PATCH, DELETE (204)
|
||||
- Use DI aliases, `response_model`, `-> Any` return type
|
||||
|
||||
7. **Register** router in `backend/app/api/routes/v1/__init__.py`
|
||||
|
||||
8. **Migration**: `cd backend && uv run alembic revision --autogenerate -m "Add <entity> table"`
|
||||
|
||||
9. **Test** (`backend/tests/`): mirror source structure
|
||||
|
||||
10. Lint: `cd backend && uv run ruff check . --fix && uv run ruff format .`
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
description: Investigate and fix an issue
|
||||
---
|
||||
|
||||
Fix the issue: $ARGUMENTS
|
||||
|
||||
1. **Understand** — search the codebase for relevant code, read the files, understand current behavior
|
||||
2. **Reproduce** — if possible, identify a test case or request that triggers the issue
|
||||
3. **Root cause** — trace through Routes → Services → Repositories to find where the bug originates
|
||||
4. **Fix** — implement the fix following project conventions:
|
||||
- Domain exceptions in services (not HTTP errors)
|
||||
- `db.flush()` in repositories (not `commit`)
|
||||
- Type hints on all changed signatures
|
||||
5. **Test** — run `cd backend && uv run pytest` to verify no regressions
|
||||
6. **Lint** — run `cd backend && uv run ruff check . --fix && uv run ruff format .`
|
||||
7. **Summary** — explain what was changed and why
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
description: Review code changes against project conventions
|
||||
---
|
||||
|
||||
Review all staged and unstaged changes in the current branch.
|
||||
|
||||
For each changed file, verify:
|
||||
|
||||
**Architecture:**
|
||||
- Routes only call services, never repositories
|
||||
- Services raise domain exceptions (NotFoundError, AlreadyExistsError, etc.), not HTTP exceptions
|
||||
- Repositories use `db.flush()` + `db.refresh()`, never `db.commit()`
|
||||
- DI uses Annotated aliases from `deps.py` (CurrentUser, *Svc), not raw `Depends()` in signatures
|
||||
|
||||
**Schemas & Types:**
|
||||
- Separate Create/Update/Read/List Pydantic models
|
||||
- Type hints on all function signatures (params + return)
|
||||
- Modern syntax: `str | None` not `Optional[str]`
|
||||
- Route return type is `-> Any`
|
||||
|
||||
**Code Quality:**
|
||||
- No debug code (print, commented-out code, TODO without issue reference)
|
||||
- No security issues (SQL injection, exposed secrets, missing auth)
|
||||
- Consistent naming (snake_case functions, PascalCase classes)
|
||||
- Imports ordered: stdlib → third-party → local
|
||||
|
||||
**Validation:**
|
||||
1. Run `cd backend && uv run ruff check .`
|
||||
2. Run `cd backend && uv run pytest` (if test files changed)
|
||||
|
||||
Provide findings with specific file:line references and suggest fixes.
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
description: API design, REST conventions, auth, pagination, response format
|
||||
globs: ["backend/app/api/**/*.py"]
|
||||
---
|
||||
|
||||
# API Conventions
|
||||
|
||||
## Route Structure
|
||||
|
||||
- All routes under `/api/v1/` prefix
|
||||
- One file per domain entity in `api/routes/v1/`
|
||||
- Use `APIRouter()` with tags
|
||||
|
||||
## HTTP Methods & Status Codes
|
||||
|
||||
```python
|
||||
# GET — read
|
||||
@router.get("/{id}", response_model=EntityRead)
|
||||
|
||||
# GET list — paginated
|
||||
@router.get("", response_model=EntityList)
|
||||
|
||||
# POST — create (201)
|
||||
@router.post("", response_model=EntityRead, status_code=status.HTTP_201_CREATED)
|
||||
|
||||
# PATCH — partial update
|
||||
@router.patch("/{id}", response_model=EntityRead)
|
||||
|
||||
# DELETE — no content (204)
|
||||
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
||||
```
|
||||
|
||||
## Pagination
|
||||
|
||||
```python
|
||||
@router.get("", response_model=ConversationList)
|
||||
async def list_items(
|
||||
service: ConversationSvc,
|
||||
user: CurrentUser,
|
||||
skip: int = Query(0, ge=0, description="Items to skip"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Max items to return"),
|
||||
) -> Any:
|
||||
items, total = await service.list(user_id=user.id, skip=skip, limit=limit)
|
||||
return ConversationList(items=items, total=total)
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
- `CurrentUser` — JWT Bearer token (any authenticated user)
|
||||
- `CurrentAdmin` — JWT + admin role check via `RoleChecker`
|
||||
- `ValidAPIKey` — API key from header (service-to-service)
|
||||
|
||||
```python
|
||||
# Protected endpoint
|
||||
async def get_profile(user: CurrentUser) -> Any: ...
|
||||
|
||||
# Admin-only endpoint
|
||||
async def delete_user(user: CurrentAdmin) -> Any: ...
|
||||
|
||||
# API key endpoint
|
||||
async def webhook_callback(api_key: ValidAPIKey) -> Any: ...
|
||||
```
|
||||
|
||||
## Response Format
|
||||
|
||||
All route handlers return `-> Any`. The `response_model` parameter handles serialization.
|
||||
|
||||
Error responses follow this JSON structure:
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "NOT_FOUND",
|
||||
"message": "User not found",
|
||||
"details": {"user_id": "..."}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## File Upload
|
||||
|
||||
```python
|
||||
@router.post("/me/avatar", response_model=UserRead)
|
||||
async def upload_avatar(
|
||||
file: UploadFile = File(...),
|
||||
user: CurrentUser,
|
||||
service: UserSvc,
|
||||
) -> Any:
|
||||
data = await file.read()
|
||||
return await service.update_avatar(user.id, data, file.filename or "avatar.jpg")
|
||||
```
|
||||
@@ -0,0 +1,138 @@
|
||||
---
|
||||
description: Layered architecture patterns — Routes, Services, Repositories, DI
|
||||
globs: ["backend/app/**/*.py"]
|
||||
---
|
||||
|
||||
# Architecture
|
||||
|
||||
## Layered Architecture: Routes → Services → Repositories
|
||||
|
||||
Routes NEVER import or call repositories directly. Always go through a service.
|
||||
|
||||
## Repositories (`app/repositories/`)
|
||||
|
||||
Pure data access — stateless functions (not classes):
|
||||
|
||||
```python
|
||||
async def get_by_id(db: AsyncSession, entity_id: UUID) -> Entity | None:
|
||||
result = await db.execute(select(Entity).where(Entity.id == entity_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create(db: AsyncSession, *, field1: str, field2: str) -> Entity:
|
||||
entity = Entity(field1=field1, field2=field2)
|
||||
db.add(entity)
|
||||
await db.flush()
|
||||
await db.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def update(db: AsyncSession, *, db_entity: Entity, update_data: dict[str, Any]) -> Entity:
|
||||
for field, value in update_data.items():
|
||||
setattr(db_entity, field, value)
|
||||
await db.flush()
|
||||
await db.refresh(db_entity)
|
||||
return db_entity
|
||||
|
||||
async def delete(db: AsyncSession, entity_id: UUID) -> Entity | None:
|
||||
entity = await get_by_id(db, entity_id)
|
||||
if entity:
|
||||
await db.delete(entity)
|
||||
await db.flush()
|
||||
return entity
|
||||
```
|
||||
|
||||
Rules:
|
||||
- ALWAYS `db.flush()` + `db.refresh()`, NEVER `db.commit()` — session auto-commits in `get_db_session`
|
||||
- Use keyword-only args after `db`: `create(db, *, email: str, name: str)`
|
||||
- Return the entity (or None for get/delete), never return IDs or dicts
|
||||
- Functions are async for PostgreSQL/MongoDB, sync for SQLite
|
||||
|
||||
## Services (`app/services/`)
|
||||
|
||||
Business logic — class-based with DB session:
|
||||
|
||||
```python
|
||||
class UserService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_by_id(self, user_id: UUID) -> User:
|
||||
user = await user_repo.get_by_id(self.db, user_id)
|
||||
if not user:
|
||||
raise NotFoundError(message="User not found", details={"user_id": user_id})
|
||||
return user
|
||||
|
||||
async def create(self, data: UserCreate) -> User:
|
||||
existing = await user_repo.get_by_email(self.db, data.email)
|
||||
if existing:
|
||||
raise AlreadyExistsError(message="Email already registered", details={"email": data.email})
|
||||
hashed_password = get_password_hash(data.password)
|
||||
return await user_repo.create(self.db, email=data.email, hashed_password=hashed_password)
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Raise domain exceptions, NEVER return error codes or None for "not found"
|
||||
- Services call repo functions, never build raw queries
|
||||
- One service per domain entity
|
||||
|
||||
## Thin vs. thick domains
|
||||
|
||||
Services come in two shapes — choose based on whether the domain owns infrastructure (clients, adapters, pipelines, parsers, templates):
|
||||
|
||||
**Thin domain → flat module (`app/services/<domain>.py`).** Default. Just a class with `db`, repo calls, and domain exceptions. Examples: `user.py`, `conversation.py`, `invitation.py`.
|
||||
|
||||
**Thick domain → subpackage (`app/services/<domain>/`).** When the domain has its own infra. The subpackage contains both the service classes AND the infra (clients, adapters, pipeline modules, domain-specific exceptions). External callers only import from the package root — sub-modules are package-internal.
|
||||
|
||||
```
|
||||
app/services/billing/
|
||||
├── __init__.py # re-exports BillingService (the public facade)
|
||||
├── facade.py # BillingService — the only thing routes see
|
||||
├── checkout_service.py # internal sub-service
|
||||
├── credit_service.py
|
||||
├── subscription_service.py
|
||||
├── webhook_handler.py
|
||||
├── stripe_client.py # external API client (infra)
|
||||
├── pricing.py # pure data
|
||||
├── exceptions.py # domain-specific, inherits from core/exceptions
|
||||
└── handlers/ # event handler modules (infra)
|
||||
```
|
||||
|
||||
Other thick domains using the same shape: `services/rag/` (ingestion + vectorstore + embeddings + connectors), `services/channels/` (Slack + Telegram adapters + router), `services/email/` (providers + templates).
|
||||
|
||||
Rules for thick subpackages:
|
||||
- Public API: only the top-level facade exported from `__init__.py`. Routes/workers never import sub-modules directly.
|
||||
- Domain-specific exceptions live in the subpackage and inherit from `core/exceptions.py` base classes.
|
||||
- Top-level `app/` is reserved for framework concerns (`api/`, `core/`, `db/`, `repositories/`, `schemas/`, `services/`, `worker/`, `agents/`, `commands/`, `clients/`). No new top-level domain packages.
|
||||
|
||||
## Dependency Injection (`app/api/deps.py`)
|
||||
|
||||
Use `Annotated` type aliases — never raw `Depends()` in route signatures:
|
||||
|
||||
```python
|
||||
DBSession = Annotated[AsyncSession, Depends(get_db_session)]
|
||||
UserSvc = Annotated[UserService, Depends(get_user_service)]
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
CurrentAdmin = Annotated[User, Depends(RoleChecker(UserRole.ADMIN))]
|
||||
```
|
||||
|
||||
Service factories take `DBSession` and return service instances:
|
||||
|
||||
```python
|
||||
def get_user_service(db: DBSession) -> UserService:
|
||||
return UserService(db)
|
||||
```
|
||||
|
||||
## Routes (`app/api/routes/v1/`)
|
||||
|
||||
HTTP layer only — validate, delegate, return:
|
||||
|
||||
```python
|
||||
@router.get("/{user_id}", response_model=UserRead)
|
||||
async def get_user(user_id: UUID, service: UserSvc, user: CurrentUser) -> Any:
|
||||
return await service.get_by_id(user_id)
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Return type is always `-> Any` (response_model handles serialization)
|
||||
- Use `status_code=status.HTTP_201_CREATED` for POST, `HTTP_204_NO_CONTENT` for DELETE
|
||||
- DELETE endpoints: `response_model=None`
|
||||
- Pagination: `skip: int = Query(0, ge=0)`, `limit: int = Query(50, ge=1, le=100)`
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
description: Code style, formatting, naming, imports, and type hints
|
||||
globs: ["backend/**/*.py", "*.py"]
|
||||
---
|
||||
|
||||
# Code Style
|
||||
|
||||
## Formatting
|
||||
|
||||
- Use `ruff` for linting and formatting: `ruff check . --fix && ruff format .`
|
||||
- Line length: 120 characters
|
||||
|
||||
## Type Hints
|
||||
|
||||
- Type hints on ALL function signatures — parameters and return types
|
||||
- Use modern syntax: `str | None` not `Optional[str]`, `list[User]` not `List[User]`
|
||||
- Use `Annotated[Type, Depends(...)]` for DI (defined as aliases in `deps.py`)
|
||||
- Use `dict[str, Any]` for generic dicts
|
||||
- Use `Literal["value1", "value2"]` for string enums in schemas
|
||||
- Use `TYPE_CHECKING` block for circular import resolution:
|
||||
```python
|
||||
from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from app.db.models.session import Session
|
||||
```
|
||||
|
||||
## Naming
|
||||
|
||||
| Element | Convention | Example |
|
||||
|---------|-----------|---------|
|
||||
| Files | snake_case | `user_repo.py`, `conversation_service.py` |
|
||||
| Classes | PascalCase | `UserService`, `ConversationRead` |
|
||||
| Functions/variables | snake_case | `get_by_id`, `user_service` |
|
||||
| Constants | UPPER_CASE | `DEFAULT_SYSTEM_PROMPT` |
|
||||
| Private | _leading_underscore | `_create_agent` |
|
||||
| DB tables | snake_case plural | `users`, `conversations` |
|
||||
| API URLs | kebab-case | `/api/v1/conversations` |
|
||||
|
||||
## Imports — strictly ordered, separated by blank lines
|
||||
|
||||
```python
|
||||
# 1. Standard library
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import UTC, datetime
|
||||
from typing import Annotated, Any
|
||||
from uuid import UUID
|
||||
|
||||
# 2. Third-party
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
# 3. Local application
|
||||
from app.api.deps import CurrentUser, UserSvc
|
||||
from app.core.exceptions import NotFoundError
|
||||
from app.schemas.user import UserCreate, UserRead
|
||||
```
|
||||
|
||||
## Other Conventions
|
||||
|
||||
- `datetime.now(UTC)` not `datetime.utcnow()`
|
||||
- `secrets.compare_digest()` for constant-time comparisons
|
||||
- `__repr__` on all DB models
|
||||
- Async for PostgreSQL/MongoDB I/O, sync for SQLite
|
||||
- Keyword-only args in repo functions after `db` parameter
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
description: Exception handling patterns and security conventions
|
||||
globs: ["backend/app/core/**/*.py", "backend/app/services/**/*.py"]
|
||||
---
|
||||
|
||||
# Exceptions & Security
|
||||
|
||||
## Domain Exceptions (`app/core/exceptions.py`)
|
||||
|
||||
All extend `AppException`. Always pass `message` and `details`:
|
||||
|
||||
```python
|
||||
raise NotFoundError(message="User not found", details={"user_id": str(user_id)})
|
||||
raise AlreadyExistsError(message="Email already registered", details={"email": email})
|
||||
raise AuthenticationError(message="Invalid or expired token")
|
||||
raise AuthorizationError(message="Role 'admin' required for this action")
|
||||
```
|
||||
|
||||
Exception handlers in `api/exception_handlers.py` automatically:
|
||||
- Map to HTTP status codes
|
||||
- Log with structured context (path, method, error code)
|
||||
- Return consistent JSON error format
|
||||
- Add `WWW-Authenticate: Bearer` header on 401
|
||||
|
||||
## Security Patterns
|
||||
|
||||
JWT auth (`core/security.py`):
|
||||
- `create_access_token(subject)` / `create_refresh_token(subject)` — encode with `jwt.encode()`
|
||||
- `verify_token(token)` → `dict | None` — decode with `jwt.decode()`
|
||||
- Token payload: `{"exp": ..., "sub": user_id, "type": "access"|"refresh"}`
|
||||
|
||||
Password hashing:
|
||||
- `get_password_hash(password)` — bcrypt
|
||||
- `verify_password(plain, hashed)` — bcrypt `checkpw`
|
||||
- NEVER store plain passwords
|
||||
|
||||
API keys:
|
||||
- `secrets.compare_digest()` for constant-time comparison
|
||||
- `APIKeyHeader(name=settings.API_KEY_HEADER, auto_error=False)`
|
||||
|
||||
## Role-Based Access Control
|
||||
|
||||
```python
|
||||
class RoleChecker:
|
||||
def __init__(self, required_role: UserRole) -> None:
|
||||
self.required_role = required_role
|
||||
|
||||
async def __call__(self, user: Annotated[User, Depends(get_current_user)]) -> User:
|
||||
if not user.has_role(self.required_role):
|
||||
raise AuthorizationError(message=f"Role '{self.required_role.value}' required")
|
||||
return user
|
||||
|
||||
CurrentAdmin = Annotated[User, Depends(RoleChecker(UserRole.ADMIN))]
|
||||
```
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
description: Frontend conventions for Next.js
|
||||
globs: ["frontend/**/*.ts", "frontend/**/*.tsx", "frontend/**/*.css"]
|
||||
---
|
||||
|
||||
# Frontend Conventions
|
||||
|
||||
## Stack
|
||||
|
||||
- Next.js 15 with App Router
|
||||
- TypeScript strict mode
|
||||
- Tailwind CSS for styling
|
||||
- i18n support built-in
|
||||
|
||||
## Structure
|
||||
|
||||
- Pages in `frontend/src/app/` following Next.js App Router conventions
|
||||
- Reusable components in `frontend/src/components/`
|
||||
- API client functions in `frontend/src/lib/`
|
||||
- Types in `frontend/src/types/`
|
||||
|
||||
## Conventions
|
||||
|
||||
- Use `"use client"` directive only when component needs client-side interactivity
|
||||
- Prefer Server Components by default
|
||||
- Use `fetch` with proper error handling for API calls
|
||||
- Keep components small and focused — extract when a component exceeds ~100 lines
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
description: Pydantic schema patterns and SQLAlchemy model conventions
|
||||
globs: ["backend/app/schemas/**/*.py", "backend/app/db/models/**/*.py", "backend/app/db/base.py"]
|
||||
---
|
||||
|
||||
# Schemas & Models
|
||||
|
||||
## Pydantic Schemas (`app/schemas/`)
|
||||
|
||||
Base schema with shared config:
|
||||
|
||||
```python
|
||||
class BaseSchema(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
populate_by_name=True,
|
||||
str_strip_whitespace=True,
|
||||
)
|
||||
```
|
||||
|
||||
Separate models per operation:
|
||||
|
||||
```python
|
||||
class UserCreate(BaseSchema):
|
||||
email: EmailStr = Field(max_length=255)
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
full_name: str | None = Field(default=None, max_length=255)
|
||||
|
||||
class UserUpdate(BaseSchema):
|
||||
email: EmailStr | None = Field(default=None, max_length=255)
|
||||
password: str | None = Field(default=None, min_length=8, max_length=128)
|
||||
full_name: str | None = Field(default=None, max_length=255)
|
||||
is_active: bool | None = None
|
||||
|
||||
class UserRead(BaseSchema, TimestampSchema):
|
||||
id: UUID
|
||||
email: EmailStr
|
||||
full_name: str | None = None
|
||||
role: UserRole = UserRole.USER
|
||||
avatar_url: str | None = None
|
||||
|
||||
class UserList(BaseSchema):
|
||||
items: list[UserRead]
|
||||
total: int
|
||||
```
|
||||
|
||||
Rules:
|
||||
- `*Create` — required fields for creation, with `Field()` constraints
|
||||
- `*Update` — all fields optional (`type | None = None`)
|
||||
- `*Read` — includes `id` and timestamps, inherits `TimestampSchema`
|
||||
- `*List` — `items` list + `total` count
|
||||
- Use `@field_validator` for complex deserialization (e.g., JSON string → dict)
|
||||
|
||||
## SQLAlchemy Models (`app/db/models/`)
|
||||
|
||||
```python
|
||||
class User(Base, TimestampMixin):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
|
||||
hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
conversations: Mapped[list["Conversation"]] = relationship(
|
||||
"Conversation", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User(id={self.id}, email={self.email})>"
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Always inherit `Base` and `TimestampMixin` (provides `created_at`, `updated_at`)
|
||||
- Use `Mapped[type]` with `mapped_column()` for all columns
|
||||
- ForeignKey with `ondelete="CASCADE"` for parent references
|
||||
- Always define `__repr__`
|
||||
- Naming convention in `Base.metadata`: `{table}_{col}_key`, `{table}_{col}_fkey`, etc.
|
||||
|
||||
## TimestampMixin
|
||||
|
||||
```python
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), onupdate=func.now(), nullable=True
|
||||
)
|
||||
```
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
description: Testing standards, fixtures, async test patterns
|
||||
globs: ["backend/tests/**/*.py", "tests/**/*.py", "**/test_*.py", "**/conftest.py"]
|
||||
---
|
||||
|
||||
# Testing
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
uv run pytest # all tests
|
||||
uv run pytest tests/test_file.py -v # single file
|
||||
uv run pytest -k "test_name" -v # by name
|
||||
uv run pytest --cov=app # with coverage
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
- Mirror source layout: `app/services/user.py` → `tests/services/test_user.py`
|
||||
- Shared fixtures in `tests/conftest.py`
|
||||
|
||||
## Naming
|
||||
|
||||
```python
|
||||
# test_<action>_<scenario>_<expected_result>
|
||||
def test_create_user_with_duplicate_email_raises_already_exists_error
|
||||
def test_get_conversation_not_found_raises_not_found_error
|
||||
def test_list_conversations_returns_only_user_owned
|
||||
```
|
||||
|
||||
## Fixtures
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
def user_service(db: AsyncSession) -> UserService:
|
||||
return UserService(db)
|
||||
|
||||
@pytest.fixture
|
||||
async def test_user(db: AsyncSession) -> User:
|
||||
return await user_repo.create(db, email="test@example.com", hashed_password="hashed")
|
||||
```
|
||||
|
||||
## Async Tests
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_by_id(user_service: UserService, test_user: User):
|
||||
result = await user_service.get_by_id(test_user.id)
|
||||
assert result.email == test_user.email
|
||||
```
|
||||
|
||||
## API Tests
|
||||
|
||||
Use `httpx.AsyncClient`, not `TestClient`:
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user(client: AsyncClient, auth_headers: dict):
|
||||
response = await client.post(
|
||||
"/api/v1/users",
|
||||
json={"email": "new@example.com", "password": "securepass123"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert response.json()["email"] == "new@example.com"
|
||||
```
|
||||
|
||||
## Exception Testing
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_not_found(user_service: UserService):
|
||||
with pytest.raises(NotFoundError):
|
||||
await user_service.get_by_id(UUID("00000000-0000-0000-0000-000000000000"))
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- Each test is independent — no shared mutable state
|
||||
- Use plain `assert` (pytest rewrites for detailed output)
|
||||
- One logical assertion per test (multiple asserts are fine if testing one behavior)
|
||||
- Use factory fixtures for test data, not raw dicts
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Read",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Bash(git status*)",
|
||||
"Bash(git diff*)",
|
||||
"Bash(git log*)",
|
||||
"Bash(git branch*)",
|
||||
"Bash(cd backend && uv run pytest*)",
|
||||
"Bash(cd backend && uv run ruff*)",
|
||||
"Bash(cd backend && uv run ty*)",
|
||||
"Bash(cd backend && uv run alembic*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.13
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --directory backend --dev
|
||||
|
||||
- name: Run ruff check
|
||||
run: uv run --directory backend ruff check app tests cli
|
||||
|
||||
- name: Run ruff format check
|
||||
run: uv run --directory backend ruff format app tests cli --check
|
||||
|
||||
- name: Run ty
|
||||
run: uv run --directory backend ty check
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: test_db
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.13
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --directory backend --dev
|
||||
|
||||
- name: Run tests
|
||||
run: uv run --directory backend pytest tests/ -v --cov=app --cov-report=xml
|
||||
env:
|
||||
POSTGRES_HOST: localhost
|
||||
POSTGRES_PORT: 5432
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: test_db
|
||||
REDIS_HOST: localhost
|
||||
REDIS_PORT: 6379
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
files: ./backend/coverage.xml
|
||||
fail_ci_if_error: false
|
||||
|
||||
test-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
working-directory: frontend
|
||||
|
||||
- name: Run lint
|
||||
run: bun run lint
|
||||
working-directory: frontend
|
||||
|
||||
- name: Run type check
|
||||
run: bun run type-check
|
||||
working-directory: frontend
|
||||
|
||||
- name: Run unit tests with coverage
|
||||
run: bun run test:coverage
|
||||
working-directory: frontend
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: bunx playwright install --with-deps
|
||||
working-directory: frontend
|
||||
|
||||
- name: Run E2E tests
|
||||
run: bun run test:e2e
|
||||
working-directory: frontend
|
||||
env:
|
||||
CI: true
|
||||
|
||||
security:
|
||||
name: Security Scan
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.13
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --directory backend --dev
|
||||
|
||||
- name: Install pip-audit
|
||||
run: uv pip install pip-audit
|
||||
|
||||
- name: Run pip-audit
|
||||
run: uv run pip-audit --require-hashes=false --progress-spinner=off
|
||||
working-directory: backend
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, test]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./backend
|
||||
push: false
|
||||
load: true
|
||||
tags: agent_delta:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
with:
|
||||
image-ref: agent_delta:latest
|
||||
format: table
|
||||
exit-code: 0
|
||||
severity: CRITICAL,HIGH
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
# Python lib directories (but not frontend src/lib)
|
||||
/lib/
|
||||
/lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# PyInstaller
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# ty
|
||||
.ty_cache/
|
||||
|
||||
# ruff
|
||||
.ruff_cache/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# pytype
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# Local development
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Docker
|
||||
.docker/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Project specific
|
||||
@@ -0,0 +1,66 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance for AI coding agents (Codex, Copilot, Cursor, Zed, OpenCode).
|
||||
|
||||
## Project Overview
|
||||
|
||||
**agent_delta** - FastAPI application generated with [Full-Stack AI Agent Template](https://github.com/vstorm-co/full-stack-ai-agent-template).
|
||||
|
||||
**Stack:** FastAPI + Pydantic v2, PostgreSQL
|
||||
, JWT + API Key auth, Redis
|
||||
, pydantic_deep (openai), RAG (qdrant), Next.js 15 (i18n)
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Run server
|
||||
cd backend && uv run uvicorn app.main:app --reload
|
||||
|
||||
# Tests & lint
|
||||
pytest
|
||||
ruff check . --fix && ruff format .
|
||||
|
||||
# Migrations
|
||||
uv run alembic upgrade head
|
||||
uv run alembic revision --autogenerate -m "Description"
|
||||
|
||||
# RAG
|
||||
uv run agent_delta rag-ingest /path/to/file.pdf --collection docs
|
||||
uv run agent_delta rag-search "query" --collection docs
|
||||
|
||||
# Sync Sources
|
||||
uv run agent_delta cmd rag-sources
|
||||
uv run agent_delta cmd rag-source-add
|
||||
uv run agent_delta cmd rag-source-sync
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
backend/app/
|
||||
├── api/routes/v1/ # Endpoints
|
||||
├── services/ # Business logic
|
||||
├── repositories/ # Data access
|
||||
├── schemas/ # Pydantic models
|
||||
├── db/models/ # DB models
|
||||
├── agents/ # AI agents
|
||||
├── rag/ # RAG (embeddings, vector store, ingestion)
|
||||
│ └── connectors/ # Sync source connectors
|
||||
└── commands/ # CLI commands
|
||||
```
|
||||
|
||||
## Key Conventions
|
||||
|
||||
- `db.flush()` in repositories, not `commit()`
|
||||
- Services raise `NotFoundError`, `AlreadyExistsError`
|
||||
- Separate `Create`, `Update`, `Response` schemas
|
||||
- Commands auto-discovered from `app/commands/`
|
||||
- Document ingestion via CLI and API upload
|
||||
- Sync sources: configurable connectors with scheduled sync
|
||||
|
||||
## More Info
|
||||
|
||||
- `docs/architecture.md` - Architecture details
|
||||
- `docs/adding_features.md` - How to add features
|
||||
- `docs/testing.md` - Testing guide
|
||||
- `docs/patterns.md` - Code patterns
|
||||
@@ -0,0 +1,153 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## Project Overview
|
||||
|
||||
**agent_delta** - FastAPI application generated with [Full-Stack AI Agent Template](https://github.com/vstorm-co/full-stack-ai-agent-template).
|
||||
|
||||
**Stack:** FastAPI + Pydantic v2, PostgreSQL (async via asyncpg)
|
||||
, JWT + API Key auth, Redis, RAG (qdrant), Taskiq, Next.js 15 (i18n)
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend
|
||||
uv run uvicorn app.main:app --reload --port 8000
|
||||
uv run pytest
|
||||
uv run pytest tests/test_file.py::test_name -v
|
||||
uv run ruff check . --fix && uv run ruff format .
|
||||
uv run ty check
|
||||
|
||||
# Database migrations
|
||||
uv run alembic upgrade head
|
||||
uv run alembic revision --autogenerate -m "Description"
|
||||
|
||||
# Frontend
|
||||
cd frontend
|
||||
bun dev
|
||||
bun test
|
||||
bun run lint
|
||||
|
||||
# Docker
|
||||
docker compose up -d
|
||||
|
||||
# RAG
|
||||
uv run agent_delta rag-collections
|
||||
uv run agent_delta rag-ingest /path/to/file.pdf --collection docs
|
||||
uv run agent_delta rag-search "query" --collection docs
|
||||
|
||||
# Sync Sources
|
||||
uv run agent_delta cmd rag-sources
|
||||
uv run agent_delta cmd rag-source-add
|
||||
uv run agent_delta cmd rag-source-sync
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
backend/app/
|
||||
├── main.py # FastAPI app with lifespan (startup/shutdown)
|
||||
├── api/
|
||||
│ ├── deps.py # Annotated DI aliases (DBSession, CurrentUser, *Svc)
|
||||
│ ├── exception_handlers.py
|
||||
│ └── routes/v1/ # HTTP endpoints — call services, never repos
|
||||
├── core/
|
||||
│ ├── config.py # pydantic-settings Settings class
|
||||
│ ├── security.py # JWT (PyJWT), bcrypt password hashing, API key verification
|
||||
│ ├── exceptions.py # Domain exceptions (AppException → NotFoundError, etc.)
|
||||
│ └── middleware.py # RequestID, SecurityHeaders, CORS
|
||||
├── db/
|
||||
│ ├── base.py # DeclarativeBase, TimestampMixin, naming convention
|
||||
│ ├── session.py # Engine, async_session_maker, get_db_session (auto-commit)
|
||||
│ └── models/ # SQLAlchemy models (Mapped[] type hints)
|
||||
├── schemas/ # Pydantic v2 models: *Create, *Update, *Read, *List
|
||||
├── repositories/ # Data access functions — db.flush(), never commit
|
||||
├── services/ # Business logic — flat *.py for thin domains, subpackage for thick
|
||||
│ ├── user.py # thin: just a class with db + repo calls
|
||||
│ ├── rag/ # thick: ingestion + vectorstore + embeddings + connectors
|
||||
├── agents/ # AI agent wrappers + tools
|
||||
├── worker/ # Background tasks (Celery/Taskiq/ARQ + in-process)
|
||||
└── commands/ # CLI commands (auto-discovered)
|
||||
```
|
||||
|
||||
## Architecture: Routes → Services → Repositories
|
||||
|
||||
**Routes** (`api/routes/v1/`) — HTTP layer only: validate input via Pydantic, call service, return response. Never import repositories.
|
||||
|
||||
**Services** (`services/`) — Business logic: class with `__init__(self, db)`, orchestrate repos, raise domain exceptions (`NotFoundError`, `AlreadyExistsError`, etc.).
|
||||
|
||||
**Repositories** (`repositories/`) — Pure data access functions. Always use `db.flush()` + `db.refresh()`, NEVER `db.commit()`. Session auto-commits via `get_db_session`.
|
||||
|
||||
## Dependency Injection Pattern
|
||||
|
||||
All DI uses `Annotated` type aliases defined in `api/deps.py`:
|
||||
|
||||
```python
|
||||
# deps.py
|
||||
DBSession = Annotated[AsyncSession, Depends(get_db_session)]
|
||||
UserSvc = Annotated[UserService, Depends(get_user_service)]
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
CurrentAdmin = Annotated[User, Depends(RoleChecker(UserRole.ADMIN))]
|
||||
|
||||
# Route usage — no raw Depends() in function signatures
|
||||
@router.get("/{id}", response_model=ConversationRead)
|
||||
async def get_conversation(
|
||||
id: UUID, service: ConversationSvc, user: CurrentUser
|
||||
) -> Any:
|
||||
return await service.get(id, user_id=user.id)
|
||||
```
|
||||
|
||||
## Schema Conventions (Pydantic v2)
|
||||
|
||||
- Base: `BaseSchema` with `ConfigDict(from_attributes=True, str_strip_whitespace=True)`
|
||||
- Separate models per operation: `*Create`, `*Update`, `*Read`
|
||||
- List responses: `*List` with `items: list[*Read]` and `total: int`
|
||||
- Update schemas: all fields `Optional` (`str | None = None`)
|
||||
- Use `Field(max_length=255)`, `Field(min_length=8)`, `EmailStr`
|
||||
- `@field_validator` for deserialization (e.g., JSON string → dict for SQLite)
|
||||
- IDs are `UUID` type
|
||||
|
||||
## Exception Handling
|
||||
|
||||
Domain exceptions in `core/exceptions.py` — all extend `AppException`:
|
||||
|
||||
| Exception | HTTP | Code |
|
||||
|-----------|------|------|
|
||||
| `NotFoundError` | 404 | `NOT_FOUND` |
|
||||
| `AlreadyExistsError` | 409 | `ALREADY_EXISTS` |
|
||||
| `ValidationError` | 422 | `VALIDATION_ERROR` |
|
||||
| `AuthenticationError` | 401 | `AUTHENTICATION_ERROR` |
|
||||
| `AuthorizationError` | 403 | `AUTHORIZATION_ERROR` |
|
||||
| `BadRequestError` | 400 | `BAD_REQUEST` |
|
||||
| `ExternalServiceError` | 503 | `EXTERNAL_SERVICE_ERROR` |
|
||||
|
||||
Always pass `message` and `details` dict: `raise NotFoundError(message="User not found", details={"user_id": id})`
|
||||
|
||||
## Response Format
|
||||
|
||||
```python
|
||||
# Single item — use response_model
|
||||
@router.get("/{id}", response_model=ConversationRead)
|
||||
|
||||
# List — return *List schema
|
||||
@router.get("", response_model=ConversationList)
|
||||
|
||||
# Create — 201
|
||||
@router.post("", response_model=UserRead, status_code=status.HTTP_201_CREATED)
|
||||
|
||||
# Delete — 204, no body
|
||||
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
||||
|
||||
# All route return types are -> Any (avoids double Pydantic validation)
|
||||
```
|
||||
|
||||
## Key Conventions
|
||||
|
||||
- Return type `-> Any` on route handlers (response_model handles serialization)
|
||||
- Use `Query(default, ge=0, le=100, description="...")` for query params
|
||||
- Keyword-only args in repo functions: `create(db, *, email: str, name: str)`
|
||||
- `__repr__` on all DB models
|
||||
- `datetime.now(UTC)` not `datetime.utcnow()`
|
||||
- `secrets.compare_digest()` for API key comparison
|
||||
- `TypedDict` for lifespan state
|
||||
- Imports: stdlib → third-party → local, with `TYPE_CHECKING` block for circular refs
|
||||
@@ -0,0 +1,64 @@
|
||||
# Contributing to agent_delta
|
||||
|
||||
## Development setup
|
||||
|
||||
```bash
|
||||
# Backend (uv-based)
|
||||
cd backend
|
||||
uv sync # install all deps including dev extras
|
||||
cp .env.example .env # then fill in required vars (see ENV_VARS.md)
|
||||
uv run uvicorn app.main:app --reload --port 8000
|
||||
uv run alembic upgrade head # apply migrations
|
||||
|
||||
# Frontend (bun-based)
|
||||
cd ../frontend
|
||||
bun install
|
||||
bun dev # http://localhost:3000
|
||||
|
||||
# Or everything in Docker
|
||||
docker compose up
|
||||
```
|
||||
|
||||
## Code style
|
||||
|
||||
- **Python:** ruff (`uv run ruff check . --fix && uv run ruff format .`). Line length 120.
|
||||
- **Type hints:** modern syntax (`str | None`, `list[X]`, `dict[str, Any]`). Use `Annotated[T, Depends(...)]` for DI in route signatures.
|
||||
- **TypeScript:** strict mode, no `any` unless typed external API. ESLint + Prettier (run `bun run lint`).
|
||||
- **Imports:** stdlib → third-party → local, separated by blank lines. Use `TYPE_CHECKING` block to break circular refs.
|
||||
- **Datetime:** `datetime.now(UTC)` not `datetime.utcnow()`.
|
||||
- **Comparisons:** `secrets.compare_digest()` for tokens/keys (constant-time).
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
uv run pytest # all backend tests
|
||||
uv run pytest tests/test_file.py::test -v # single test
|
||||
uv run pytest -k "name_substring" -v # by name pattern
|
||||
uv run pytest --cov=app # with coverage
|
||||
```
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
bun test # vitest
|
||||
bunx tsc --noEmit # type-check without emit
|
||||
```
|
||||
|
||||
## Architecture rules
|
||||
|
||||
- **Routes** never import repositories directly. Always go through a service.
|
||||
- **Services** raise domain exceptions (`NotFoundError`, `AlreadyExistsError`) — never return `None` for "not found".
|
||||
- **Repositories** use `db.flush()` + `db.refresh()`, NEVER `db.commit()` (session auto-commits in `get_db_session`).
|
||||
- **Pydantic schemas:** separate `*Create`, `*Update`, `*Read`, `*List` per operation.
|
||||
- **Migrations:** one Alembic revision per logical change; never edit a merged migration.
|
||||
|
||||
See `docs/architecture.md` for the full layered architecture rules.
|
||||
|
||||
## Pull-request checklist
|
||||
|
||||
- [ ] `uv run ruff check . && uv run ruff format --check .` clean
|
||||
- [ ] `cd frontend && bunx tsc --noEmit` clean
|
||||
- [ ] Tests added for new code paths; `uv run pytest` green
|
||||
- [ ] If schema changed: alembic migration committed (`uv run alembic revision --autogenerate -m "..."`)
|
||||
- [ ] Updated `ENV_VARS.md` if new env vars added
|
||||
- [ ] Updated `CHANGELOG.md` (if applicable)
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
# Environment variables
|
||||
|
||||
Reference for `agent_delta` runtime configuration. The
|
||||
authoritative source is `backend/.env.example` — this doc explains what each
|
||||
group is for and which are required vs optional.
|
||||
|
||||
> Quick start: copy `backend/.env.example` to `backend/.env` and fill in the
|
||||
> blanks marked **Required**. Defaults are sensible for local development.
|
||||
|
||||
## Project
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `PROJECT_NAME` | optional | `agent_delta` | Used in logs, OpenAPI title, email templates |
|
||||
| `DEBUG` | optional | `true` | When `true`, FastAPI returns full tracebacks |
|
||||
| `ENVIRONMENT` | optional | `local` | Free-form tag: `local` / `staging` / `production` |
|
||||
| `TIMEZONE` | optional | `UTC` | IANA TZ name (e.g. `Europe/Warsaw`) |
|
||||
| `BACKEND_URL` | optional | `http://localhost:8000` | Used by frontend BFF + email link generation |
|
||||
| `FRONTEND_URL` | optional | `http://localhost:3000` | Used by password-reset / magic-link emails |
|
||||
|
||||
## Auth & secrets
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `SECRET_KEY` | **required in prod** | (generated) | JWT signing key. Rotating invalidates all tokens |
|
||||
| `API_KEY` | **required in prod** | (generated) | Static admin/service-to-service key for `X-API-Key` header |
|
||||
| `ACCESS_TOKEN_EXPIRE_MINUTES` | optional | `30` | JWT access token lifetime |
|
||||
| `REFRESH_TOKEN_EXPIRE_MINUTES` | optional | `10080` | JWT refresh token lifetime (7 days) |
|
||||
|
||||
## Database
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `DATABASE_URL` | **required** | `postgresql+asyncpg://...` | Full async connection string |
|
||||
| `DB_POOL_SIZE` | optional | `5` | Number of long-lived connections |
|
||||
| `DB_MAX_OVERFLOW` | optional | `10` | Burst capacity above pool size |
|
||||
|
||||
## LLM / AI
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `OPENAI_API_KEY` | **required** | — | From platform.openai.com |
|
||||
| `AI_MODEL` | optional | `gpt-5.5` | Default model used by agent (provider-specific) |
|
||||
| `LOGFIRE_TOKEN` | optional | — | When set, ships traces to Logfire (logfire.pydantic.dev) |
|
||||
|
||||
## RAG (qdrant)
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `QDRANT_URL` | **required** | `http://localhost:6333` | Qdrant REST endpoint |
|
||||
| `QDRANT_API_KEY` | optional | — | Auth (cloud Qdrant) |
|
||||
|
||||
## Redis
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `REDIS_URL` | **required** | `redis://localhost:6379/0` | Used by cache, rate-limiter, session store |
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
# Confirm settings load without errors:
|
||||
cd backend && uv run python -c "from app.core.config import settings; print(settings.model_dump_json(indent=2))"
|
||||
```
|
||||
|
||||
If any **Required** var is missing, FastAPI raises `pydantic_settings.SettingsError` on startup — check the message for which field.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Manual setup steps for agent_delta
|
||||
|
||||
The generator created the code. These are the **one-time external setup steps**
|
||||
that can't be automated — accounts to create, keys to copy, services to provision.
|
||||
|
||||
> Skip ahead to "After every deploy" at the bottom for things you'll re-do
|
||||
> regularly. Items above are one-time per environment.
|
||||
|
||||
---
|
||||
|
||||
## Secrets
|
||||
|
||||
```bash
|
||||
cp backend/.env.example backend/.env
|
||||
```
|
||||
|
||||
Then in `backend/.env`:
|
||||
|
||||
- [ ] **`SECRET_KEY`** — replace with a fresh value: `openssl rand -hex 32`
|
||||
- [ ] **`API_KEY`** — replace with a fresh value: `openssl rand -hex 32`
|
||||
|
||||
These are used to sign JWTs and authenticate service-to-service calls. Rotate at every environment promotion (dev → staging → prod each get their own).
|
||||
|
||||
|
||||
## PostgreSQL
|
||||
|
||||
- [ ] Provision a PostgreSQL ≥ 14 instance (local: `docker compose up -d db`; managed: Neon / Supabase / RDS / Cloud SQL).
|
||||
- [ ] Set `DATABASE_URL` in `.env` to the **async** connection string: `postgresql+asyncpg://user:pass@host:5432/dbname`.
|
||||
- [ ] Run migrations: `cd backend && uv run alembic upgrade head`.
|
||||
|
||||
## OpenAI
|
||||
|
||||
- [ ] Create API key at https://platform.openai.com/api-keys.
|
||||
- [ ] Set `OPENAI_API_KEY` in `.env`.
|
||||
- [ ] (Optional) Set spending limit on OpenAI dashboard to avoid surprise bills.
|
||||
|
||||
## RAG (qdrant)
|
||||
|
||||
- [ ] Local: `docker compose up -d qdrant`.
|
||||
- [ ] Cloud: provision Qdrant Cloud, set `QDRANT_URL` + `QDRANT_API_KEY`.
|
||||
|
||||
- [ ] (Optional) Ingest seed documents: `uv run agent_delta rag-ingest /path/to/file.pdf --collection docs`.
|
||||
|
||||
## Redis
|
||||
|
||||
- [ ] Local: `docker compose up -d redis` (already in compose file).
|
||||
- [ ] Managed: Upstash / Redis Cloud / ElastiCache. Set `REDIS_URL` in `.env`.
|
||||
|
||||
## Logfire (Pydantic observability)
|
||||
|
||||
- [ ] Create account at https://logfire.pydantic.dev.
|
||||
- [ ] Run `uv run logfire auth` once locally to bootstrap.
|
||||
- [ ] Get write token → set `LOGFIRE_TOKEN` in `.env` for non-local environments.
|
||||
|
||||
---
|
||||
|
||||
## After every deploy
|
||||
|
||||
- [ ] Run database migrations: `alembic upgrade head` (CI step or post-deploy job).
|
||||
- [ ] Smoke test `/api/v1/health` returns `{"status": "ok"}`.
|
||||
- [ ] Frontend loads, login → dashboard flow works.
|
||||
- [ ] Logs flowing to your aggregator.
|
||||
|
||||
---
|
||||
|
||||
## Where to find more
|
||||
|
||||
- `ENV_VARS.md` — exhaustive env var reference
|
||||
- `docs/deploy.md` — platform-specific deployment recipes
|
||||
- `SECURITY.md` — security model + production hardening checklist
|
||||
- `CONTRIBUTING.md` — dev environment setup
|
||||
- `docs/architecture.md` — codebase layered architecture rules
|
||||
@@ -0,0 +1,377 @@
|
||||
.PHONY: install format lint test run clean help db-init dev dev-down dev-logs dev-rebuild dev-frontend docker-clean stage stage-down prod prod-down
|
||||
|
||||
# === Environments ===========================================================
|
||||
# `make dev` — local development (docker-compose.dev.yml + bind-mounted source)
|
||||
# `make stage` — staging (docker-compose.yml — built images, no live reload)
|
||||
# `make prod` — production (docker-compose.prod.yml — needs backend/.env + nginx)
|
||||
# Each env has matching -down / -logs / -rebuild siblings.
|
||||
|
||||
# Wait for postgres to accept connections. Polls pg_isready instead of a
|
||||
# fixed sleep — handles slow startups and cold-start image pulls.
|
||||
define _wait_for_db
|
||||
@echo "Waiting for PostgreSQL ($(1))..."
|
||||
@for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do \
|
||||
if docker compose -f $(1) exec -T db pg_isready -U postgres >/dev/null 2>&1; then \
|
||||
echo " ✅ DB ready"; exit 0; \
|
||||
fi; \
|
||||
printf '.'; sleep 2; \
|
||||
done; \
|
||||
echo " ❌ DB not ready after 30s — check 'make dev-logs'"; exit 1
|
||||
endef
|
||||
|
||||
# Fold the opt-in "antv" profile into `make dev` so the diagram sidecar starts
|
||||
# automatically alongside db/app. The agent connects only when ENABLE_ANTV_CHARTS=true.
|
||||
COMPOSE_DEV_PROFILES := --profile antv
|
||||
|
||||
# === Local dev: build → up → migrate ===
|
||||
# Idempotent — re-run anytime. Migrations are no-ops when already at head;
|
||||
# admin seeding is a separate target (`make seed`) so re-running `make dev`
|
||||
# doesn't keep retrying user creation.
|
||||
dev:
|
||||
@echo "▶ Building backend image…"
|
||||
docker compose -f docker-compose.dev.yml build app
|
||||
@echo "▶ Starting services…"
|
||||
@if ! docker compose -f docker-compose.dev.yml $(COMPOSE_DEV_PROFILES) up -d; then \
|
||||
echo ""; \
|
||||
echo "⚠ First start failed. Tearing down stale containers and retrying once…"; \
|
||||
echo " (volumes preserved — DB data is safe; use 'make clean' for a full wipe)"; \
|
||||
docker compose -f docker-compose.dev.yml down --remove-orphans; \
|
||||
docker compose -f docker-compose.dev.yml $(COMPOSE_DEV_PROFILES) up -d; \
|
||||
fi
|
||||
$(call _wait_for_db,docker-compose.dev.yml)
|
||||
@echo "▶ Applying migrations…"
|
||||
docker compose -f docker-compose.dev.yml exec -T app agent_delta db upgrade
|
||||
@echo ""
|
||||
@echo "🚀 Dev stack ready:"
|
||||
@echo " API: http://localhost:8000"
|
||||
@echo " Docs: http://localhost:8000/docs"
|
||||
@echo " Admin: http://localhost:8000/admin"
|
||||
@echo " Frontend: http://localhost:3000 (run 'make dev-frontend' or 'cd frontend && bun dev')"
|
||||
@echo ""
|
||||
@echo "First time? Run 'make seed' to create the default admin user."
|
||||
|
||||
# === First-time setup: seed default admin user (one-shot) ===
|
||||
# Skipped when admin@example.com already exists. Safe to run again — exits
|
||||
# clean either way. Replace email/password before deploying anywhere real.
|
||||
seed:
|
||||
@echo "▶ Seeding admin user (admin@example.com / admin123)…"
|
||||
@if docker compose -f docker-compose.dev.yml exec -T app \
|
||||
agent_delta user list 2>/dev/null \
|
||||
| grep -q "admin@example.com"; then \
|
||||
echo " (admin@example.com already exists — nothing to do)"; \
|
||||
else \
|
||||
docker compose -f docker-compose.dev.yml exec -T app \
|
||||
agent_delta user create \
|
||||
--email admin@example.com --password admin123 --superuser \
|
||||
&& echo " ✅ Admin created. Login at http://localhost:8000/admin"; \
|
||||
fi
|
||||
|
||||
# Convenience: bootstrap a fresh checkout end-to-end.
|
||||
bootstrap: dev seed
|
||||
|
||||
dev-down:
|
||||
docker compose -f docker-compose.dev.yml $(COMPOSE_DEV_PROFILES) down
|
||||
|
||||
# Full wipe — containers, networks, AND volumes. Use after a corrupted state
|
||||
# (e.g. detached networks, port conflicts that left orphans). DESTROYS DB data.
|
||||
docker-clean:
|
||||
@echo "▶ Removing containers, networks, AND volumes for the dev stack…"
|
||||
@echo " ⚠️ This deletes all local DB data and uploaded files."
|
||||
docker compose -f docker-compose.dev.yml $(COMPOSE_DEV_PROFILES) down -v --remove-orphans
|
||||
@echo "✅ Cleaned. Run 'make dev' to start fresh."
|
||||
|
||||
dev-logs:
|
||||
docker compose -f docker-compose.dev.yml $(COMPOSE_DEV_PROFILES) logs -f
|
||||
|
||||
dev-rebuild:
|
||||
docker compose -f docker-compose.dev.yml build --no-cache app
|
||||
docker compose -f docker-compose.dev.yml up -d --force-recreate app
|
||||
dev-frontend:
|
||||
docker compose -f docker-compose.frontend.yml up -d
|
||||
@echo ""
|
||||
@echo "✅ Frontend at http://localhost:3000 (backend must be up — 'make dev')"
|
||||
|
||||
# === Staging: built images, no bind mounts (production-like, local DB) ===
|
||||
stage:
|
||||
docker compose -f docker-compose.yml up -d --build
|
||||
$(call _wait_for_db,docker-compose.yml)
|
||||
docker compose -f docker-compose.yml exec -T app agent_delta db upgrade
|
||||
@echo "✅ Staging stack at http://localhost:8000"
|
||||
|
||||
stage-down:
|
||||
docker compose -f docker-compose.yml down
|
||||
|
||||
# === Production: external Nginx, real secrets in backend/.env ===
|
||||
prod:
|
||||
@test -f backend/.env || (echo "❌ backend/.env missing — run 'cp backend/.env.example backend/.env' and fill in real secrets" && exit 1)
|
||||
docker compose --env-file backend/.env -f docker-compose.prod.yml up -d --build
|
||||
@echo "▶ Waiting for DB then running migrations…"
|
||||
@sleep 5
|
||||
docker compose --env-file backend/.env -f docker-compose.prod.yml exec -T app agent_delta db upgrade
|
||||
@echo "✅ Production stack up. Configure your nginx host with nginx/nginx.conf"
|
||||
|
||||
prod-down:
|
||||
docker compose --env-file backend/.env -f docker-compose.prod.yml down
|
||||
|
||||
prod-logs:
|
||||
docker compose --env-file backend/.env -f docker-compose.prod.yml logs -f
|
||||
|
||||
# Legacy alias
|
||||
quickstart: dev
|
||||
|
||||
# === Setup ===
|
||||
install:
|
||||
uv sync --directory backend --dev
|
||||
@echo ""
|
||||
@echo "✅ Installation complete!"
|
||||
@echo ""
|
||||
@echo "Next steps:"
|
||||
@echo " • make docker-db # Start PostgreSQL"
|
||||
@echo " • make db-upgrade # Apply migrations"
|
||||
@echo " • make run # Start development server"
|
||||
@echo ""
|
||||
@echo "Note: backend/.env is pre-configured for development"
|
||||
|
||||
# === Code Quality ===
|
||||
format:
|
||||
uv run --directory backend ruff format app tests cli
|
||||
uv run --directory backend ruff check app tests cli --fix
|
||||
|
||||
lint:
|
||||
uv run --directory backend ruff check app tests cli
|
||||
uv run --directory backend ruff format app tests cli --check
|
||||
uv run --directory backend ty check
|
||||
|
||||
# === Testing ===
|
||||
test:
|
||||
uv run --directory backend pytest tests/ -v
|
||||
|
||||
test-cov:
|
||||
uv run --directory backend pytest tests/ -v --cov=app --cov-report=html --cov-report=term-missing
|
||||
|
||||
# === Database ===
|
||||
db-init: docker-db
|
||||
@echo "Waiting for PostgreSQL to be ready..."
|
||||
@sleep 8
|
||||
cd backend && uv run agent_delta db migrate -m "initial" || true
|
||||
cd backend && uv run agent_delta db upgrade
|
||||
@echo ""
|
||||
@echo "✅ Database initialized!"
|
||||
|
||||
db-migrate:
|
||||
@read -p "Migration message: " msg; \
|
||||
uv run --directory backend agent_delta db migrate -m "$$msg"
|
||||
|
||||
db-upgrade:
|
||||
uv run --directory backend agent_delta db upgrade
|
||||
|
||||
db-downgrade:
|
||||
uv run --directory backend agent_delta db downgrade
|
||||
|
||||
db-current:
|
||||
uv run --directory backend agent_delta db current
|
||||
|
||||
db-history:
|
||||
uv run --directory backend agent_delta db history
|
||||
|
||||
# === Server ===
|
||||
run:
|
||||
uv run --directory backend agent_delta server run --reload
|
||||
|
||||
run-prod:
|
||||
uv run --directory backend agent_delta server run --host 0.0.0.0 --port 8000
|
||||
|
||||
routes:
|
||||
uv run --directory backend agent_delta server routes
|
||||
|
||||
# === Users ===
|
||||
create-admin:
|
||||
@echo "Creating admin user..."
|
||||
uv run --directory backend agent_delta user create-admin
|
||||
|
||||
user-create:
|
||||
uv run --directory backend agent_delta user create
|
||||
|
||||
user-list:
|
||||
uv run --directory backend agent_delta user list
|
||||
|
||||
# === Taskiq ===
|
||||
taskiq-worker:
|
||||
uv run --directory backend agent_delta taskiq worker
|
||||
|
||||
taskiq-scheduler:
|
||||
uv run --directory backend agent_delta taskiq scheduler
|
||||
|
||||
# === Docker: Backend (Development) ===
|
||||
docker-up:
|
||||
docker-compose build app
|
||||
docker-compose up -d
|
||||
@echo ""
|
||||
@echo "✅ Backend services started!"
|
||||
@echo " API: http://localhost:8000"
|
||||
@echo " Docs: http://localhost:8000/docs"
|
||||
@echo " PostgreSQL: localhost:5432"
|
||||
@echo " Redis: localhost:6379"
|
||||
|
||||
docker-down:
|
||||
docker-compose down
|
||||
docker-compose -f docker-compose.frontend.yml down 2>/dev/null || true
|
||||
|
||||
docker-logs:
|
||||
docker-compose logs -f
|
||||
|
||||
docker-build:
|
||||
docker-compose build
|
||||
|
||||
docker-shell:
|
||||
docker-compose exec app /bin/bash
|
||||
|
||||
# === Docker: Frontend (Development) ===
|
||||
docker-frontend:
|
||||
docker-compose -f docker-compose.frontend.yml up -d
|
||||
@echo ""
|
||||
@echo "✅ Frontend started!"
|
||||
@echo " URL: http://localhost:3000"
|
||||
@echo ""
|
||||
@echo "Note: Backend must be running (make docker-up)"
|
||||
|
||||
docker-frontend-down:
|
||||
docker-compose -f docker-compose.frontend.yml down
|
||||
|
||||
docker-frontend-logs:
|
||||
docker-compose -f docker-compose.frontend.yml logs -f
|
||||
|
||||
docker-frontend-build:
|
||||
docker-compose -f docker-compose.frontend.yml build
|
||||
|
||||
# === Docker: Production (with Traefik) ===
|
||||
docker-prod:
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
@echo ""
|
||||
@echo "✅ Production services started with Traefik!"
|
||||
@echo ""
|
||||
@echo "Endpoints (replace DOMAIN with your domain):"
|
||||
@echo " Frontend: https://$$DOMAIN"
|
||||
@echo " API: https://api.$$DOMAIN"
|
||||
@echo " Traefik: https://traefik.$$DOMAIN"
|
||||
|
||||
docker-prod-down:
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
|
||||
docker-prod-logs:
|
||||
docker-compose -f docker-compose.prod.yml logs -f
|
||||
|
||||
docker-prod-build:
|
||||
docker-compose -f docker-compose.prod.yml build
|
||||
|
||||
# === Docker: Individual Services ===
|
||||
docker-db:
|
||||
docker-compose up -d db
|
||||
@echo ""
|
||||
@echo "✅ PostgreSQL started on port 5432"
|
||||
@echo " Connection: postgresql://postgres:postgres@localhost:5432/agent_delta"
|
||||
|
||||
docker-db-stop:
|
||||
docker-compose stop db
|
||||
|
||||
docker-redis:
|
||||
docker-compose up -d redis
|
||||
@echo ""
|
||||
@echo "✅ Redis started on port 6379"
|
||||
|
||||
docker-redis-stop:
|
||||
docker-compose stop redis
|
||||
|
||||
# === Vercel (Frontend Deployment) ===
|
||||
vercel-deploy:
|
||||
cd frontend && npx vercel --prod
|
||||
@echo ""
|
||||
@echo "✅ Frontend deployed to Vercel!"
|
||||
@echo " Set environment variables in Vercel dashboard:"
|
||||
@echo " BACKEND_URL=https://api.your-domain.com"
|
||||
@echo " BACKEND_WS_URL=wss://api.your-domain.com"
|
||||
@echo " NEXT_PUBLIC_AUTH_ENABLED=true"
|
||||
@echo " NEXT_PUBLIC_RAG_ENABLED=true"
|
||||
|
||||
# === Cleanup ===
|
||||
clean:
|
||||
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type d -name .pytest_cache -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type d -name .ruff_cache -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type d -name .ty_cache -exec rm -rf {} + 2>/dev/null || true
|
||||
rm -rf htmlcov/ .coverage coverage.xml
|
||||
|
||||
# === Help ===
|
||||
help:
|
||||
@echo ""
|
||||
@echo "agent_delta - Available Commands"
|
||||
@echo "======================================"
|
||||
@echo ""
|
||||
@echo "🚀 Bootstrap (first-time setup):"
|
||||
@echo " make bootstrap 'make dev' + 'make seed' — full setup from a fresh clone"
|
||||
@echo ""
|
||||
@echo "Day-to-day dev:"
|
||||
@echo " make dev Build + start dev stack + apply migrations (idempotent)"
|
||||
@echo " make seed One-shot admin seed (admin@example.com / admin123)"
|
||||
@echo " make dev-down Stop dev stack"
|
||||
@echo " make dev-logs Tail dev container logs"
|
||||
@echo " make dev-rebuild Force-rebuild backend image"
|
||||
@echo " make docker-clean Wipe containers + networks + volumes (DESTROYS data)"
|
||||
@echo " make dev-frontend Start frontend container (after 'make dev')"
|
||||
@echo ""
|
||||
@echo "📦 Other environments:"
|
||||
@echo " make stage Production-like stack on localhost (no bind mounts)"
|
||||
@echo " make prod Production stack (requires backend/.env + nginx)"
|
||||
@echo ""
|
||||
@echo "Setup (without Docker):"
|
||||
@echo " make install Install Python deps + pre-commit hooks"
|
||||
@echo ""
|
||||
@echo "Development:"
|
||||
@echo " make run Start dev server (with hot reload)"
|
||||
@echo " make test Run tests"
|
||||
@echo " make lint Check code quality"
|
||||
@echo " make format Auto-format code"
|
||||
@echo ""
|
||||
@echo "Database:"
|
||||
@echo " make db-init Initialize database (start + migrate)"
|
||||
@echo " make db-migrate Create new migration"
|
||||
@echo " make db-upgrade Apply migrations"
|
||||
@echo " make db-downgrade Rollback last migration"
|
||||
@echo " make db-current Show current migration"
|
||||
@echo ""
|
||||
@echo "Users:"
|
||||
@echo " make create-admin Create admin user (for SQLAdmin access)"
|
||||
@echo " make user-create Create new user (interactive)"
|
||||
@echo " make user-list List all users"
|
||||
@echo ""
|
||||
@echo "RAG:"
|
||||
@echo " uv run agent_delta rag-ingest <path> -c <collection> Ingest files"
|
||||
@echo " uv run agent_delta rag-search <query> -c <collection> Search"
|
||||
@echo " uv run agent_delta rag-collections List collections"
|
||||
@echo " uv run agent_delta rag-sources List sync sources"
|
||||
@echo " uv run agent_delta rag-source-add Add sync source"
|
||||
@echo " uv run agent_delta rag-source-sync <id> Trigger sync"
|
||||
@echo ""
|
||||
@echo "Taskiq:"
|
||||
@echo " make taskiq-worker Start Taskiq worker"
|
||||
@echo " make taskiq-scheduler Start Taskiq scheduler"
|
||||
@echo ""
|
||||
@echo "Docker (Development):"
|
||||
@echo " make docker-up Start backend services"
|
||||
@echo " make docker-down Stop all services"
|
||||
@echo " make docker-logs View backend logs"
|
||||
@echo " make docker-build Build backend images"
|
||||
@echo " make docker-frontend Start frontend (separate)"
|
||||
@echo " make docker-frontend-down Stop frontend"
|
||||
@echo " make docker-db Start only PostgreSQL"
|
||||
@echo " make docker-redis Start only Redis"
|
||||
@echo ""
|
||||
@echo "Docker (Production with Traefik):"
|
||||
@echo " make docker-prod Start production stack"
|
||||
@echo " make docker-prod-down Stop production stack"
|
||||
@echo " make docker-prod-logs View production logs"
|
||||
@echo ""
|
||||
@echo "Other:"
|
||||
@echo " make routes Show all API routes"
|
||||
@echo " make clean Clean cache files"
|
||||
@echo ""
|
||||
@@ -0,0 +1,277 @@
|
||||
# agent_delta
|
||||
|
||||
agent delta project
|
||||
|
||||
> Generated with [Full-Stack AI Agent Template](https://github.com/vstorm-co/full-stack-ai-agent-template).
|
||||
|
||||
---
|
||||
|
||||
## Stack
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|-----------|
|
||||
| **Backend** | FastAPI + Pydantic v2 |
|
||||
| **Database** | PostgreSQL (async via asyncpg) |
|
||||
| **Auth** | JWT + refresh tokens + API keys |
|
||||
| **Cache** | Redis |
|
||||
| **AI Framework** | pydantic_deep (openai) |
|
||||
| **RAG** | qdrant vector store |
|
||||
| **Tasks** | taskiq |
|
||||
| **Frontend** | Next.js 15 + React 19 + Tailwind v4 |
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Tool | Version | Install |
|
||||
|---|---|---|
|
||||
| **Docker** | Desktop / Engine 24+ | <https://docs.docker.com/get-docker/> |
|
||||
| **Make** | GNU Make 3.81+ (preinstalled on macOS/Linux) | Windows: install via [chocolatey](https://chocolatey.org/) `choco install make` or use WSL2 |
|
||||
| **uv** | latest | `curl -LsSf https://astral.sh/uv/install.sh \| sh` |
|
||||
| **bun** | 1.x | `curl -fsSL https://bun.sh/install \| bash` (or use `npm` / `pnpm` if you prefer) |
|
||||
|
||||
> **Windows users:** the Makefile and shell helpers assume bash. Use **WSL2** or **Git Bash** for the smoothest experience. The Docker workflow below works identically on macOS, Linux, and WSL2.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start (Local Dev)
|
||||
|
||||
### First time
|
||||
|
||||
```bash
|
||||
make bootstrap # = make dev + make seed
|
||||
```
|
||||
|
||||
That's the only command you need on a fresh clone. After this, day-to-day is just `make dev`.
|
||||
|
||||
### Subsequent runs
|
||||
|
||||
```bash
|
||||
make dev
|
||||
```
|
||||
|
||||
`make dev` is **idempotent** — re-run it any time. It will:
|
||||
|
||||
1. Build the backend Docker image (cached after first run)
|
||||
2. Start services via `docker-compose.dev.yml` (with hot-reload bind mounts)
|
||||
3. Poll Postgres until it accepts connections (`pg_isready` — no fixed sleeps)
|
||||
4. Apply pending Alembic migrations (no-op if already at head)
|
||||
|
||||
It does **not** re-seed the admin user — that lives in `make seed` and is run once. This way `make dev` stays cheap to re-run after every code/config change.
|
||||
|
||||
**Then access:**
|
||||
|
||||
- API: <http://localhost:8000>
|
||||
- Docs: <http://localhost:8000/docs>
|
||||
- Admin: <http://localhost:8000/admin> — `admin@example.com` / `admin123` after `make seed`
|
||||
- Frontend: <http://localhost:3000> — start with `make dev-frontend` (Docker) or `cd frontend && bun install && bun dev` (local)
|
||||
|
||||
### Day-to-day commands
|
||||
|
||||
```bash
|
||||
make dev # bootstrap or restart (idempotent, no admin re-seed)
|
||||
make seed # one-shot admin creation (no-op if admin already exists)
|
||||
make dev-down # stop everything
|
||||
make dev-logs # tail logs (Ctrl-C to exit)
|
||||
make dev-rebuild # force-rebuild backend image (after pyproject.toml change)
|
||||
make dev-frontend # start the Next.js container
|
||||
```
|
||||
|
||||
If you prefer running the backend on the host (not in Docker) — useful for breakpoints / IDE debugging:
|
||||
|
||||
```bash
|
||||
make install # uv sync + pre-commit install
|
||||
docker compose -f docker-compose.dev.yml up -d db redis milvus etcd minio
|
||||
make db-upgrade # apply migrations
|
||||
make run # run uvicorn locally with --reload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environments
|
||||
|
||||
| `make` target | Compose file | Use case |
|
||||
|---|---|---|
|
||||
| `make dev` | `docker-compose.dev.yml` | Local development with hot-reload + bind-mounted source. |
|
||||
| `make stage` | `docker-compose.yml` | Production-like build, no bind mounts, runs on localhost. Good for sanity-checking before deploy. |
|
||||
| `make prod` | `docker-compose.prod.yml` | Production. Requires `backend/.env` (copy from `backend/.env.example`, fill real secrets) and an external Nginx using `nginx/nginx.conf`. |
|
||||
|
||||
Each env has matching `-down`, `-logs`, `-rebuild` siblings (e.g. `make stage-down`).
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
backend/app/
|
||||
├── main.py # FastAPI app + lifespan
|
||||
├── api/
|
||||
│ ├── deps.py # Annotated DI aliases (DBSession, CurrentUser, *Svc)
|
||||
│ ├── exception_handlers.py
|
||||
│ └── routes/v1/ # HTTP endpoints — call services, never repos
|
||||
├── core/
|
||||
│ ├── config.py # pydantic-settings (reads .env)
|
||||
│ ├── security.py # JWT, bcrypt, API key verification
|
||||
│ ├── exceptions.py # AppException → NotFound / Auth / etc.
|
||||
│ └── middleware.py
|
||||
├── db/
|
||||
│ ├── base.py # DeclarativeBase + TimestampMixin
|
||||
│ └── models/ # SQLAlchemy models (Mapped[] type hints)
|
||||
├── schemas/ # Pydantic v2: *Create / *Update / *Read / *List
|
||||
├── repositories/ # Data access — db.flush() never commit
|
||||
├── services/ # Business logic — raises domain exceptions
|
||||
├── agents/ # AI agent wrappers + tools
|
||||
├── rag/ # RAG: vectorstore + embeddings + ingestion + sources
|
||||
│ └── connectors/ # Pluggable sync sources (Google Drive, S3, …)
|
||||
├── worker/
|
||||
│ ├── background/ # FastAPI BackgroundTasks fallback (in-process)
|
||||
│ └── tasks/ # Distributed tasks (taskiq)
|
||||
└── commands/ # Click CLI commands (auto-discovered by `agent_delta cmd …`)
|
||||
|
||||
frontend/src/
|
||||
├── app/
|
||||
│ ├── [locale]/ # next-intl routes (en/pl)
|
||||
│ │ └── (dashboard)/ # Authenticated app
|
||||
│ └── api/ # Server-side API proxies (forward auth cookies)
|
||||
├── components/ # React components (chat, marketing, ui primitives)
|
||||
├── hooks/ # useAuth, useChat, useConversations, …
|
||||
├── stores/ # Zustand stores
|
||||
└── lib/ # api-client, server-api, utils
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI
|
||||
|
||||
The generated project ships a Click CLI exposed as `agent_delta` (after `make install`):
|
||||
|
||||
```bash
|
||||
agent_delta server run --reload # dev server
|
||||
agent_delta db upgrade # apply migrations
|
||||
agent_delta db migrate -m "message" # create new migration
|
||||
agent_delta user create-admin # interactive admin creation
|
||||
agent_delta rag-ingest <path> -c docs # ingest local files
|
||||
agent_delta rag-search "query" -c docs # semantic search
|
||||
agent_delta rag-collections # list collections
|
||||
```
|
||||
|
||||
Run `make help` for a categorized list, or `agent_delta --help` for full CLI docs.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All backend config lives in `backend/.env` (committed for dev defaults). Key variables:
|
||||
|
||||
```bash
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
POSTGRES_DB=agent_delta
|
||||
|
||||
# OpenAI — required for chat + embeddings
|
||||
OPENAI_API_KEY=sk-…
|
||||
```
|
||||
|
||||
See `backend/.env.example` for the full list with comments.
|
||||
|
||||
For production, **never** commit secrets — `backend/.env` is gitignored. Fill it with real values on the server (or inject them via your platform's secret manager: Doppler, AWS Secrets Manager, GitHub Actions secrets, etc.). The same `backend/.env` is used for dev and prod — there is no separate `.env.prod`.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `make test` | Run pytest |
|
||||
| `make lint` | Run ruff check + format check + ty |
|
||||
| `make format` | Auto-format with ruff |
|
||||
| `make db-migrate` | Generate a new migration from model changes (interactive) |
|
||||
| `make db-upgrade` | Apply pending migrations |
|
||||
| `make db-downgrade` | Roll back one migration |
|
||||
| `make db-current` | Show current head |
|
||||
| `make create-admin` | Interactive admin creation |
|
||||
| `make user-list` | List all users |
|
||||
|
||||
---
|
||||
|
||||
## RAG (Knowledge Base)
|
||||
|
||||
Using **qdrant** as the vector store with **openai** embeddings.
|
||||
|
||||
```bash
|
||||
# Ingest local files (recursive)
|
||||
agent_delta rag-ingest /path/to/docs/ --collection documents --recursive
|
||||
|
||||
# Semantic search
|
||||
agent_delta rag-search "your query" --collection documents
|
||||
```
|
||||
|
||||
PDF parsing uses **pymupdf**. See `docs/howto/add-rag-source.md` to add a new source connector.
|
||||
|
||||
---
|
||||
|
||||
## Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
bun install
|
||||
bun dev # http://localhost:3000
|
||||
bun run lint
|
||||
bun run build
|
||||
```
|
||||
|
||||
The frontend talks to the backend through Next.js API route handlers in `src/app/api/*` (server-side proxy that forwards auth cookies to the FastAPI backend). Direct calls to `localhost:8000` from the browser are deliberately avoided.
|
||||
|
||||
i18n (PL + EN) ships out of the box via `next-intl`. Add a new locale by extending `messages/<lang>.json` and `src/i18n.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
### Frontend → Vercel
|
||||
|
||||
```bash
|
||||
cd frontend && npx vercel --prod
|
||||
```
|
||||
|
||||
Set in the Vercel dashboard:
|
||||
|
||||
- `BACKEND_URL` = `https://api.your-domain.com`
|
||||
- `BACKEND_WS_URL` = `wss://api.your-domain.com`
|
||||
- `NEXT_PUBLIC_AUTH_ENABLED` = `true`
|
||||
- `NEXT_PUBLIC_RAG_ENABLED` = `true`
|
||||
|
||||
### Backend → your server
|
||||
|
||||
```bash
|
||||
# 1. SSH to the box, clone the repo
|
||||
# 2. cp backend/.env.example backend/.env, fill in real secrets
|
||||
# 3. Configure nginx using nginx/nginx.conf as reference
|
||||
# 4. Bring up the stack:
|
||||
make prod
|
||||
|
||||
# Day-to-day:
|
||||
make prod-logs
|
||||
make prod-down
|
||||
```
|
||||
|
||||
Migrations run automatically on `make prod`. For a fresh deploy on a new host, the same `make prod` is the bootstrap command.
|
||||
|
||||
---
|
||||
|
||||
## Guides
|
||||
|
||||
| Guide | What |
|
||||
|-------|-------|
|
||||
| `docs/howto/add-api-endpoint.md` | Add a new REST endpoint |
|
||||
| `docs/howto/add-agent-tool.md` | Create an agent tool |
|
||||
| `docs/howto/customize-agent-prompt.md` | Tune system prompts |
|
||||
| `docs/howto/add-background-task.md` | Add a background task |
|
||||
| `docs/howto/add-rag-source.md` | Add a RAG document source |
|
||||
| `docs/howto/add-sync-connector.md` | Build a custom sync connector |
|
||||
|
||||
---
|
||||
|
||||
*Generated with [Full-Stack AI Agent Template](https://github.com/vstorm-co/full-stack-ai-agent-template) v0.2.11.*
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
# Security
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Email: **your@email.com** (or open a private security advisory on the repo). Please include:
|
||||
|
||||
- Affected version / commit
|
||||
- Steps to reproduce
|
||||
- Impact assessment (data exposure / privilege escalation / DoS / …)
|
||||
|
||||
We aim to acknowledge within 48h and ship a fix within 7 days for high-severity issues.
|
||||
|
||||
---
|
||||
|
||||
## Security model
|
||||
|
||||
### Authentication
|
||||
- **JWT (`HS256`)** signed with `SECRET_KEY`. Access token TTL = `ACCESS_TOKEN_EXPIRE_MINUTES` (default 30 min). Refresh token TTL = `REFRESH_TOKEN_EXPIRE_MINUTES` (default 7 days).
|
||||
- **Password hashing:** bcrypt via `passlib`. Plain passwords never persisted.
|
||||
- **Stateless JWT** — no DB session table. Logout is client-side (drop tokens). For server-side revocation, regenerate with `--session-management`.
|
||||
- **Admin API key** — static `settings.API_KEY` matched via `X-API-Key` header for service-to-service calls. Constant-time compared with `secrets.compare_digest()`.
|
||||
|
||||
### Authorization
|
||||
|
||||
- **Role-based** via `RoleChecker` dep (`UserRole.USER` / `UserRole.ADMIN`).
|
||||
|
||||
### Transport / network
|
||||
|
||||
- **CORS** — origin list from `settings.CORS_ORIGINS`. Restrict to your domains in production.
|
||||
- **HTTPS** — enforce via reverse proxy (Nginx / Traefik / ALB). Strict-Transport-Security header set in middleware when `ENVIRONMENT=production`.
|
||||
- **CSP** — frontend sets `frame-ancestors 'none'` by default to prevent click-jacking. See `frontend/next.config.ts` headers block.
|
||||
|
||||
### Data
|
||||
|
||||
- **Secrets** — read from environment via `pydantic-settings`. Never committed. See `.env.example` + `ENV_VARS.md`.
|
||||
- **Audit log** — admin-mutating actions (user updates, deletes, impersonations, role changes) recorded in `app_admin_audit_log` table with actor + IP + payload snapshot.
|
||||
- **RAG documents** — file uploads scoped per-org. No public read endpoint; all retrieval happens server-side during chat.
|
||||
|
||||
### Hardening checklist for production
|
||||
|
||||
- [ ] Rotate `SECRET_KEY` and `API_KEY` from generated defaults.
|
||||
- [ ] Set `DEBUG=false` and `ENVIRONMENT=production`.
|
||||
- [ ] Restrict `CORS_ORIGINS` to your domain(s).
|
||||
- [ ] Tune `RATE_LIMIT_REQUESTS` / `RATE_LIMIT_PERIOD` in `.env`.
|
||||
- [ ] Enforce HTTPS at the proxy layer.
|
||||
- [ ] Run `pip-audit` / `bun audit` in CI for dependency vulnerabilities.
|
||||
- [ ] Configure database backups + restore test schedule.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **No 2FA / MFA** out of the box. Plan to add TOTP via `pyotp` — see `notes/thingstofix.md` §A.13.
|
||||
- **No SAML / OIDC** beyond Google OAuth. Enterprise SSO needs custom IdP integration.
|
||||
- **No automatic PII redaction** in logs — be careful what you log.
|
||||
- **No server-side session revocation** — JWTs valid until expiry. Compromised tokens require `SECRET_KEY` rotation (invalidates ALL sessions). Enable `--session-management` for selective revocation.
|
||||
@@ -0,0 +1,57 @@
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Python
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
.venv
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Testing
|
||||
.pytest_cache
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox
|
||||
.nox
|
||||
|
||||
# Documentation
|
||||
docs/_build/
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
# Build artifacts
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# Development files
|
||||
.env
|
||||
.env.local
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
# Docker
|
||||
Dockerfile*
|
||||
docker-compose*.yml
|
||||
.docker
|
||||
|
||||
# CI/CD
|
||||
.github/
|
||||
.gitlab-ci.yml
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.log
|
||||
@@ -0,0 +1,114 @@
|
||||
# agent_delta Environment Variables
|
||||
|
||||
# === Project ===
|
||||
PROJECT_NAME=agent_delta
|
||||
DEBUG=true
|
||||
ENVIRONMENT=local
|
||||
TIMEZONE=UTC # IANA timezone: UTC, Europe/Warsaw, America/New_York
|
||||
MODELS_CACHE_DIR=./models_cache
|
||||
|
||||
# === Logfire ===
|
||||
# Get your token at https://logfire.pydantic.dev
|
||||
LOGFIRE_TOKEN=
|
||||
LOGFIRE_SERVICE_NAME=agent_delta
|
||||
LOGFIRE_ENVIRONMENT=development
|
||||
|
||||
# === PostgreSQL ===
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
POSTGRES_DB=agent_delta
|
||||
|
||||
# === JWT Auth ===
|
||||
# Generate with: openssl rand -hex 32
|
||||
SECRET_KEY=change-me-in-production-use-openssl-rand-hex-32
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=10080
|
||||
ALGORITHM=HS256
|
||||
|
||||
# === API Key Auth ===
|
||||
API_KEY=change-me-in-production
|
||||
API_KEY_HEADER=X-API-Key
|
||||
|
||||
# === Redis ===
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
# REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
|
||||
# === RAG Configuration ===
|
||||
RAG_DEFAULT_COLLECTION=documents
|
||||
RAG_TOP_K=10
|
||||
RAG_CHUNK_SIZE=512
|
||||
RAG_CHUNK_OVERLAP=50
|
||||
RAG_CHUNKING_STRATEGY=recursive # recursive, markdown, or fixed
|
||||
RAG_HYBRID_SEARCH=false # Enable BM25 + vector hybrid search
|
||||
RAG_ENABLE_OCR=false # OCR fallback for scanned PDFs (requires tesseract-ocr installed)
|
||||
# Vector Database (Qdrant)
|
||||
QDRANT_HOST=localhost
|
||||
QDRANT_PORT=6333
|
||||
QDRANT_API_KEY=
|
||||
# Reranker
|
||||
HF_TOKEN=
|
||||
CROSS_ENCODER_MODEL=cross-encoder/ms-marco-MiniLM-L6-v2
|
||||
|
||||
# === Taskiq ===
|
||||
TASKIQ_BROKER_URL=redis://localhost:6379/1
|
||||
TASKIQ_RESULT_BACKEND=redis://localhost:6379/1
|
||||
|
||||
|
||||
# === AI Agent (pydantic_deep, openai) ===
|
||||
OPENAI_API_KEY=
|
||||
AI_MODEL=gpt-5.5
|
||||
AI_TEMPERATURE=0.7
|
||||
|
||||
# === Web Search (Tavily) ===
|
||||
# Get your API key at https://tavily.com
|
||||
TAVILY_API_KEY=
|
||||
|
||||
# === AntV charts (advanced diagrams via mcp-server-chart sidecar) ===
|
||||
# Opt-in. The create_map tool works without this; only the AntV diagram tools
|
||||
# need the sidecar, which `make dev` starts automatically.
|
||||
ENABLE_ANTV_CHARTS=false
|
||||
# MCP endpoint of the antvis-chart sidecar (default matches docker-compose).
|
||||
ANTV_MCP_URL=http://antvis-chart:1122/mcp
|
||||
# Optional self-hosted GPT-Vis render backend (empty = AntV's public service).
|
||||
ANTV_VIS_REQUEST_SERVER=
|
||||
# Comma-separated AntV tools to disable. Leave empty to use the sidecar's
|
||||
# default (drops basic charts that overlap create_chart, and China-only maps).
|
||||
ANTV_DISABLED_TOOLS=
|
||||
# Embeddings
|
||||
# OpenAI Embeddings
|
||||
OPENAI_API_KEY=
|
||||
EMBEDDING_MODEL=text-embedding-3-small
|
||||
|
||||
# Chunking
|
||||
RAG_CHUNK_SIZE=512
|
||||
RAG_CHUNK_OVERLAP=50
|
||||
|
||||
# Retrieval
|
||||
RAG_DEFAULT_COLLECTION=documents
|
||||
RAG_TOP_K=10
|
||||
|
||||
# Reranker
|
||||
|
||||
# PDF Parser
|
||||
|
||||
# Google Drive (optional, for document ingestion)
|
||||
|
||||
# === CORS ===
|
||||
# JSON list of allowed origins (default: localhost:3000, localhost:8080)
|
||||
# Note: "*" is blocked in production - specify explicit origins
|
||||
CORS_ORIGINS=["http://localhost:3000","http://localhost:8080"]
|
||||
|
||||
# === Docker Production (Traefik) ===
|
||||
# Domain for production deployment
|
||||
DOMAIN=example.com
|
||||
|
||||
# Let's Encrypt email for SSL certificates
|
||||
ACME_EMAIL=admin@example.com
|
||||
|
||||
# Traefik dashboard auth (generate with: htpasswd -nb admin password)
|
||||
# TRAEFIK_DASHBOARD_AUTH=admin:$$apr1$$...
|
||||
# Redis password for production
|
||||
REDIS_PASSWORD=change-me-in-production
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
# Pre-commit is disabled for this project
|
||||
@@ -0,0 +1,54 @@
|
||||
|
||||
# Build stage
|
||||
FROM python:3.13-slim AS builder
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
WORKDIR /app
|
||||
|
||||
# Install uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
|
||||
ENV UV_COMPILE_BYTECODE=1
|
||||
ENV UV_LINK_MODE=copy
|
||||
|
||||
# Install dependencies
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
uv sync --frozen --no-install-project --no-dev
|
||||
|
||||
# Copy application
|
||||
COPY . /app
|
||||
|
||||
# Install project
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev
|
||||
|
||||
|
||||
# Runtime stage
|
||||
FROM python:3.13-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
WORKDIR /app
|
||||
|
||||
# Copy virtual environment from builder
|
||||
COPY --from=builder /app/.venv /app/.venv
|
||||
COPY --from=builder /app /app
|
||||
|
||||
# Add venv to path
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
# Create non-root user and writable directories
|
||||
RUN adduser --disabled-password --gecos "" appuser && \
|
||||
mkdir -p /app/media /app/data /app/chroma_data /app/models_cache && \
|
||||
chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python -c "import httpx; httpx.get('http://localhost:8000/api/v1/health')" || exit 1
|
||||
|
||||
CMD ["python", "-m", "cli.commands", "server", "run", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,45 @@
|
||||
|
||||
# Alembic Configuration
|
||||
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
# Human-readable migration file names: 2024-01-15_add_users_table.py
|
||||
file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(slug)s
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Alembic migration environment."""
|
||||
# ruff: noqa: I001 - Imports structured for Jinja2 template conditionals
|
||||
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# Import all models here to ensure they are registered with metadata
|
||||
from app.db.models.user import User # noqa: F401
|
||||
from app.db.models.conversation import Conversation, Message, ToolCall # noqa: F401
|
||||
from app.db.models.message_rating import MessageRating # noqa: F401
|
||||
from app.db.models.webhook import Webhook, WebhookDelivery # noqa: F401
|
||||
from app.db.models.chat_file import ChatFile # noqa: F401
|
||||
from app.db.models.rag_document import RAGDocument # noqa: F401
|
||||
from app.db.models.sync_log import SyncLog # noqa: F401
|
||||
from app.db.models.sync_source import SyncSource # noqa: F401
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
target_metadata = SQLModel.metadata
|
||||
|
||||
|
||||
# Ensure SQLite data directory exists before connecting
|
||||
|
||||
|
||||
def get_url() -> str:
|
||||
"""Get database URL from settings."""
|
||||
return settings.DATABASE_URL_SYNC
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode."""
|
||||
url = get_url()
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode."""
|
||||
configuration = config.get_section(config.config_ini_section) or {}
|
||||
configuration["sqlalchemy.url"] = get_url()
|
||||
|
||||
connectable = engine_from_config(
|
||||
configuration,
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,28 @@
|
||||
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,53 @@
|
||||
"""create users table
|
||||
|
||||
Revision ID: 0000_users
|
||||
Revises:
|
||||
Create Date: 2026-06-15T06:18:30.904341+00:00
|
||||
|
||||
Base table required by every later migration. Mirrors the current User model
|
||||
including is_app_admin (later flagged in 0003 — included here so the table
|
||||
is usable immediately when enable_teams=false) and onboarding_completed_at
|
||||
(0016 mirror — same reason). OAuth columns are present only when an OAuth
|
||||
provider was selected, keeping the schema minimal for password-only setups.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0000_users"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column(
|
||||
"id",
|
||||
PG_UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column("email", sa.String(255), nullable=False, unique=True, index=True),
|
||||
sa.Column("hashed_password", sa.String(255), nullable=True),
|
||||
sa.Column("full_name", sa.String(255), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("role", sa.String(50), nullable=False, server_default="user"),
|
||||
sa.Column("is_app_admin", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("avatar_url", sa.String(500), nullable=True),
|
||||
sa.Column("onboarding_completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("users")
|
||||
@@ -0,0 +1,18 @@
|
||||
"""create organization tables — skipped (enable_teams=false or no SQL DB)
|
||||
|
||||
Revision ID: 0001_org
|
||||
"""
|
||||
# This migration is a no-op when enable_teams is false.
|
||||
|
||||
revision = "0001_org"
|
||||
down_revision = "0000_users"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,17 @@
|
||||
"""backfill personal orgs — skipped (enable_teams=false or no SQL DB)
|
||||
|
||||
Revision ID: 0002_backfill_orgs
|
||||
"""
|
||||
|
||||
revision = "0002_backfill_orgs"
|
||||
down_revision = "0001_org"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,17 @@
|
||||
"""add is_app_admin — skipped (enable_teams=false or no SQL DB)
|
||||
|
||||
Revision ID: 0003_is_app_admin
|
||||
"""
|
||||
|
||||
revision = "0003_is_app_admin"
|
||||
down_revision = "0002_backfill_orgs"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,246 @@
|
||||
"""create core tables (conversations, messages, chat_files, sessions, rag, channels)
|
||||
|
||||
Revision ID: 0004_5_core_tables
|
||||
Revises: 0004_audit_log
|
||||
Create Date: 2026-06-15T06:18:30.904341+00:00
|
||||
|
||||
Creates the core conversational, RAG, channel, and session tables that
|
||||
later migrations modify (0005, 0006, 0007, 0009). Splits feature-flagged
|
||||
groups so the schema only contains what was selected at generation time.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0004_5_core_tables"
|
||||
down_revision = "0004_audit_log"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _id_col() -> sa.Column:
|
||||
return sa.Column(
|
||||
"id",
|
||||
PG_UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
)
|
||||
|
||||
|
||||
def _user_fk(*, nullable: bool, ondelete: str = "CASCADE") -> sa.Column:
|
||||
return sa.Column(
|
||||
"user_id",
|
||||
PG_UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete=ondelete),
|
||||
nullable=nullable,
|
||||
)
|
||||
|
||||
|
||||
_UUID = PG_UUID(as_uuid=True)
|
||||
_JSONB = postgresql.JSONB(astext_type=sa.Text())
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"conversations",
|
||||
_id_col(),
|
||||
_user_fk(nullable=True),
|
||||
sa.Column("title", sa.String(255), nullable=True),
|
||||
sa.Column("is_archived", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index("ix_conversations_user_id", "conversations", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"messages",
|
||||
_id_col(),
|
||||
sa.Column(
|
||||
"conversation_id",
|
||||
_UUID,
|
||||
sa.ForeignKey("conversations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("role", sa.String(20), nullable=False),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("model_name", sa.String(100), nullable=True),
|
||||
sa.Column("tokens_used", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index("ix_messages_conversation_id", "messages", ["conversation_id"])
|
||||
|
||||
op.create_table(
|
||||
"tool_calls",
|
||||
_id_col(),
|
||||
sa.Column(
|
||||
"message_id", _UUID, sa.ForeignKey("messages.id", ondelete="CASCADE"), nullable=False
|
||||
),
|
||||
sa.Column("tool_call_id", sa.String(100), nullable=False),
|
||||
sa.Column("tool_name", sa.String(100), nullable=False),
|
||||
sa.Column("args", _JSONB, nullable=False),
|
||||
sa.Column("result", sa.Text(), nullable=True),
|
||||
sa.Column("status", sa.String(20), nullable=False),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("duration_ms", sa.Integer(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_tool_calls_message_id", "tool_calls", ["message_id"])
|
||||
|
||||
op.create_table(
|
||||
"chat_files",
|
||||
_id_col(),
|
||||
_user_fk(nullable=False),
|
||||
sa.Column(
|
||||
"message_id", _UUID, sa.ForeignKey("messages.id", ondelete="CASCADE"), nullable=True
|
||||
),
|
||||
sa.Column("filename", sa.String(255), nullable=False),
|
||||
sa.Column("mime_type", sa.String(100), nullable=False),
|
||||
sa.Column("size", sa.Integer(), nullable=False),
|
||||
sa.Column("storage_path", sa.String(500), nullable=False),
|
||||
sa.Column("file_type", sa.String(20), nullable=False),
|
||||
sa.Column("parsed_content", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index("ix_chat_files_user_id", "chat_files", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"conversation_shares",
|
||||
_id_col(),
|
||||
sa.Column(
|
||||
"conversation_id",
|
||||
_UUID,
|
||||
sa.ForeignKey("conversations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"shared_by", _UUID, sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"shared_with", _UUID, sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=True
|
||||
),
|
||||
sa.Column("share_token", sa.String(64), nullable=True, unique=True),
|
||||
sa.Column("permission", sa.String(10), nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.UniqueConstraint("conversation_id", "shared_with", name="uq_share_conv_user"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_conversation_shares_conversation_id", "conversation_shares", ["conversation_id"]
|
||||
)
|
||||
op.create_index("ix_conversation_shares_shared_with", "conversation_shares", ["shared_with"])
|
||||
|
||||
op.create_table(
|
||||
"message_ratings",
|
||||
_id_col(),
|
||||
sa.Column(
|
||||
"message_id", _UUID, sa.ForeignKey("messages.id", ondelete="CASCADE"), nullable=False
|
||||
),
|
||||
_user_fk(nullable=False),
|
||||
sa.Column("rating", sa.Integer(), nullable=False),
|
||||
sa.Column("comment", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.CheckConstraint("rating IN (1, -1)", name="message_ratings_ck_rating_value_check"),
|
||||
sa.UniqueConstraint("message_id", "user_id", name="uq_message_user_rating"),
|
||||
)
|
||||
op.create_index("ix_message_ratings_message_id", "message_ratings", ["message_id"])
|
||||
op.create_index("ix_message_ratings_user_id", "message_ratings", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"rag_documents",
|
||||
_id_col(),
|
||||
sa.Column("collection_name", sa.String(255), nullable=False),
|
||||
sa.Column("filename", sa.String(255), nullable=False),
|
||||
sa.Column("filesize", sa.Integer(), nullable=False),
|
||||
sa.Column("filetype", sa.String(20), nullable=False),
|
||||
sa.Column("storage_path", sa.String(500), nullable=True),
|
||||
sa.Column("status", sa.String(20), nullable=False),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column("vector_document_id", sa.String(255), nullable=True),
|
||||
sa.Column("chunk_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column(
|
||||
"started_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index("ix_rag_documents_collection_name", "rag_documents", ["collection_name"])
|
||||
|
||||
op.create_table(
|
||||
"sync_sources",
|
||||
_id_col(),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column("connector_type", sa.String(20), nullable=False),
|
||||
sa.Column("collection_name", sa.String(255), nullable=False),
|
||||
sa.Column("config", _JSONB, server_default="{}", nullable=False),
|
||||
sa.Column("sync_mode", sa.String(20), nullable=False),
|
||||
sa.Column("schedule_minutes", sa.Integer(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("last_sync_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_sync_status", sa.String(20), nullable=True),
|
||||
sa.Column("last_error", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index("ix_sync_sources_collection_name", "sync_sources", ["collection_name"])
|
||||
|
||||
op.create_table(
|
||||
"sync_logs",
|
||||
_id_col(),
|
||||
sa.Column("source", sa.String(20), nullable=False),
|
||||
sa.Column("collection_name", sa.String(255), nullable=False),
|
||||
sa.Column(
|
||||
"sync_source_id",
|
||||
_UUID,
|
||||
sa.ForeignKey("sync_sources.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("status", sa.String(20), nullable=False),
|
||||
sa.Column("mode", sa.String(20), nullable=False),
|
||||
sa.Column("total_files", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("ingested", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("updated", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("skipped", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("failed", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"started_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index("ix_sync_logs_collection_name", "sync_logs", ["collection_name"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("sync_logs")
|
||||
op.drop_table("sync_sources")
|
||||
op.drop_table("rag_documents")
|
||||
op.drop_table("message_ratings")
|
||||
op.drop_table("conversation_shares")
|
||||
op.drop_table("chat_files")
|
||||
op.drop_table("tool_calls")
|
||||
op.drop_table("messages")
|
||||
op.drop_table("conversations")
|
||||
@@ -0,0 +1,17 @@
|
||||
"""create audit log — skipped (enable_teams=false or no SQL DB)
|
||||
|
||||
Revision ID: 0004_audit_log
|
||||
"""
|
||||
|
||||
revision = "0004_audit_log"
|
||||
down_revision = "0003_is_app_admin"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,17 @@
|
||||
"""add organization_id to conversations — skipped (enable_teams=false or no SQL DB)
|
||||
|
||||
Revision ID: 0005_org_tenant_isolation
|
||||
"""
|
||||
|
||||
revision = "0005_org_tenant_isolation"
|
||||
down_revision = "0004_5_core_tables"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,17 @@
|
||||
"""backfill organization_id on conversations — skipped (enable_teams=false or no JWT)
|
||||
|
||||
Revision ID: 0006_backfill_conv_org
|
||||
"""
|
||||
|
||||
revision = "0006_backfill_conv_org"
|
||||
down_revision = "0005_org_tenant_isolation"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,17 @@
|
||||
"""create knowledge_bases — skipped (enable_teams/enable_rag/use_jwt=false or no SQL DB)
|
||||
|
||||
Revision ID: 0007_knowledge_bases
|
||||
"""
|
||||
|
||||
revision = "0007_knowledge_bases"
|
||||
down_revision = "0006_backfill_conv_org"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,17 @@
|
||||
"""backfill default KBs — skipped (enable_teams/enable_rag/use_jwt=false or no SQL DB)
|
||||
|
||||
Revision ID: 0008_backfill_default_kbs
|
||||
"""
|
||||
|
||||
revision = "0008_backfill_default_kbs"
|
||||
down_revision = "0007_knowledge_bases"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,17 @@
|
||||
"""add active_knowledge_base_ids to conversations — skipped (enable_teams/enable_rag/use_jwt=false or no SQL DB)
|
||||
|
||||
Revision ID: 0009_conv_active_kb_ids
|
||||
"""
|
||||
|
||||
revision = "0009_conv_active_kb_ids"
|
||||
down_revision = "0008_backfill_default_kbs"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,17 @@
|
||||
"""add billing seats to organizations — skipped (enable_billing/enable_teams=false or no SQL DB)
|
||||
|
||||
Revision ID: 0010_org_billing_seats
|
||||
"""
|
||||
|
||||
revision = "0010_org_billing_seats"
|
||||
down_revision = "0009_conv_active_kb_ids"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,17 @@
|
||||
"""create plan and price tables — skipped (enable_billing=false or no SQL DB)
|
||||
|
||||
Revision ID: 0011_create_plan_price_tables
|
||||
"""
|
||||
|
||||
revision = "0011_create_plan_price_tables"
|
||||
down_revision = "0010_org_billing_seats"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,17 @@
|
||||
"""create subscription table — skipped (enable_billing=false or no SQL DB)
|
||||
|
||||
Revision ID: 0012_create_subscription_table
|
||||
"""
|
||||
|
||||
revision = "0012_create_subscription_table"
|
||||
down_revision = "0011_create_plan_price_tables"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,17 @@
|
||||
"""create stripe_event table — skipped (enable_billing=false or no SQL DB)
|
||||
|
||||
Revision ID: 0013_create_stripe_event_table
|
||||
"""
|
||||
|
||||
revision = "0013_create_stripe_event_table"
|
||||
down_revision = "0012_create_subscription_table"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,17 @@
|
||||
"""create credit_transaction and usage_event — skipped (enable_billing/enable_credits_system=false or no SQL DB)
|
||||
|
||||
Revision ID: 0014_credits_usage_events
|
||||
"""
|
||||
|
||||
revision = "0014_credits_usage_events"
|
||||
down_revision = "0013_create_stripe_event_table"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,18 @@
|
||||
"""create mv_usage_daily — skipped (PostgreSQL + billing + credits not all enabled)
|
||||
|
||||
Revision ID: 0015_create_mv_usage_daily
|
||||
Revises: 0014_credits_usage_events
|
||||
"""
|
||||
|
||||
revision = "0015_create_mv_usage_daily"
|
||||
down_revision = "0014_credits_usage_events"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,44 @@
|
||||
"""add onboarding_completed_at to users
|
||||
|
||||
Revision ID: 0016_user_onboarding_at
|
||||
Revises: 0015_create_mv_usage_daily
|
||||
Create Date: 2026-05-08T00:00:00+00:00
|
||||
|
||||
Adds:
|
||||
- users.onboarding_completed_at — nullable timestamptz; null means the user
|
||||
hasn't completed onboarding yet, set when they finish the wizard.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0016_user_onboarding_at"
|
||||
down_revision = "0015_create_mv_usage_daily"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Idempotent — `0000_users` already includes this column when running on a
|
||||
# fresh DB. Older deployments that stamped past 0000 still need it added.
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
cols = {c["name"] for c in inspector.get_columns("users")}
|
||||
if "onboarding_completed_at" not in cols:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column(
|
||||
"onboarding_completed_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
cols = {c["name"] for c in inspector.get_columns("users")}
|
||||
if "onboarding_completed_at" in cols:
|
||||
op.drop_column("users", "onboarding_completed_at")
|
||||
@@ -0,0 +1,65 @@
|
||||
"""create user_slash_commands table
|
||||
|
||||
Revision ID: 0018_user_slash_commands
|
||||
Revises: 0017_create_api_keys_table
|
||||
Create Date: 2026-05-10T00:00:00+00:00
|
||||
|
||||
Stores per-user slash command settings for the chat palette:
|
||||
- Custom commands (``prompt`` is set) — quick prompt shortcuts.
|
||||
- Built-in overrides (``prompt`` is NULL) — record only ``is_enabled`` for
|
||||
one of the frontend's BUILTIN_COMMANDS, so users can hide ones they don't
|
||||
use.
|
||||
|
||||
The unique ``(user_id, name)`` constraint prevents both classes of row from
|
||||
co-existing for the same name on a single user.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0018_user_slash_commands"
|
||||
down_revision = "0016_user_onboarding_at"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"user_slash_commands",
|
||||
sa.Column(
|
||||
"id",
|
||||
PG_UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
PG_UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("name", sa.String(64), nullable=False),
|
||||
sa.Column("prompt", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"is_enabled",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.true(),
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.UniqueConstraint("user_id", "name", name="uq_user_slash_commands_user_name"),
|
||||
)
|
||||
op.create_index("ix_user_slash_commands_user_id", "user_slash_commands", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_user_slash_commands_user_id", table_name="user_slash_commands")
|
||||
op.drop_table("user_slash_commands")
|
||||
@@ -0,0 +1,3 @@
|
||||
"""agent delta project"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,10 @@
|
||||
"""AI Agents module using PydanticDeep.
|
||||
|
||||
This module contains a deep agentic coding assistant built with pydantic-deep.
|
||||
PydanticDeep is built on PydanticAI and provides filesystem operations,
|
||||
task management, subagent delegation, skills, memory, and Docker sandbox support.
|
||||
"""
|
||||
|
||||
from app.agents.pydantic_deep_assistant import PydanticDeepAssistant, PydanticDeepContext
|
||||
|
||||
__all__ = ["PydanticDeepAssistant", "PydanticDeepContext"]
|
||||
@@ -0,0 +1,96 @@
|
||||
"""System prompts for AI agents.
|
||||
|
||||
Centralized location for all agent prompts to make them easy to find and modify.
|
||||
|
||||
The default prompt follows an outcome-first style: it defines who the assistant
|
||||
is, how it should behave, and how to format answers — then trusts the model to
|
||||
choose a good path. Avoid re-introducing long process checklists or absolute
|
||||
"ALWAYS / NEVER / EXCLUSIVELY" rules for judgment calls; they make the assistant
|
||||
mechanical and, in the RAG case, cause it to wrongly refuse general questions.
|
||||
"""
|
||||
|
||||
DEFAULT_SYSTEM_PROMPT = """You are a knowledgeable, capable AI assistant. Help the user accomplish their task or answer their question as well as you can.
|
||||
|
||||
# Personality
|
||||
Be approachable, steady, and direct. Assume the user is competent and acting in good faith. Prefer making progress over stopping for clarification when the request is clear enough to attempt — use reasonable assumptions and state them briefly. Ask a narrow clarifying question only when the missing information would materially change the answer.
|
||||
|
||||
Stay concise without being curt: give enough context for the user to understand and trust the answer, then stop. Use examples or simple analogies when they make a point land. When correcting the user or disagreeing, be candid but constructive; if you are wrong, acknowledge it plainly and fix it. Match the user's tone within professional bounds, and avoid emojis and profanity unless the user clearly invites that style.
|
||||
|
||||
# Answering
|
||||
Answer from your own broad knowledge by default. You are a general-purpose assistant, not a document-lookup bot — questions about the world, concepts, code, math, science, history, culture, writing, and everyday advice should be answered directly and helpfully.
|
||||
|
||||
Say you don't know only when the answer genuinely depends on private, user-specific, or very recent information you cannot access. Never refuse or hedge on a general-knowledge question just because the topic isn't in a connected data source. If a request is ambiguous, answer the most likely intent and note the assumption rather than stalling.
|
||||
|
||||
# Output
|
||||
Let formatting serve comprehension. Default to clear plain paragraphs for explanations and discussion. Reach for headers, bullets, or numbered lists only when they genuinely make the answer easier to scan — steps, comparisons, or rankings — or when the user asks for them. Honor explicit formatting and length preferences from the user. Lead with the conclusion, then the supporting detail, then any caveats."""
|
||||
|
||||
DEFAULT_SYSTEM_PROMPT += """
|
||||
|
||||
# Maps
|
||||
You can render an interactive map with the `create_map` tool. Use it whenever the
|
||||
user wants to see places located geographically (cities, offices, routes, points
|
||||
of interest). Supply latitude/longitude for each marker from your own knowledge
|
||||
(e.g. Warsaw ≈ 52.23, 21.01; New York ≈ 40.71, -74.01). Give each marker a short
|
||||
label, and an optional description. Don't repeat the JSON — briefly describe the
|
||||
map you created. Each map is rendered to the user the moment you call
|
||||
`create_map`; a map from an earlier turn is already on screen, so never re-create
|
||||
it — only call `create_map` for the user's current request."""
|
||||
|
||||
|
||||
# AntV diagram tools attach only at runtime (when ENABLE_ANTV_CHARTS is set), so
|
||||
# their guidance is gated the same way. `create_map` above is always available.
|
||||
ANTV_CHART_GUIDANCE = """
|
||||
|
||||
# Advanced diagrams
|
||||
Beyond `create_chart`, you have AntV `generate_*` tools for diagram types the
|
||||
basic chart tool can't express — flowcharts, mind maps, org charts, sankey,
|
||||
fishbone, network/graph, treemap, word clouds, radar, funnel, histogram, and
|
||||
more. Use them when the user asks for that specific diagram, or when the
|
||||
relationship is structural (process, hierarchy, flow) rather than a plain
|
||||
numeric series. Prefer `create_chart` for ordinary line/bar/pie/area/scatter.
|
||||
|
||||
Keep every node, label, and description short — a few words at most. Many of
|
||||
these diagrams render nodes in a fixed-width box and truncate longer text with
|
||||
an ellipsis ("…"), so write "Verify email", not "Send the verification email and
|
||||
wait for confirmation". Put any detail in your reply, not in the node.
|
||||
|
||||
After the tool returns an image, briefly describe it — don't paste the URL. The
|
||||
image is shown to the user immediately; a diagram from an earlier turn is already
|
||||
on screen, so never regenerate it — only call these tools for the current request."""
|
||||
|
||||
|
||||
def _antv_guidance() -> str:
|
||||
"""AntV diagram guidance — included only when the MCP sidecar is enabled."""
|
||||
from app.core.config import settings
|
||||
|
||||
return ANTV_CHART_GUIDANCE if settings.ENABLE_ANTV_CHARTS else ""
|
||||
|
||||
|
||||
DEFAULT_SYSTEM_PROMPT += _antv_guidance()
|
||||
|
||||
|
||||
def get_system_prompt_with_rag() -> str:
|
||||
"""Get the default prompt plus knowledge-base (RAG) usage guidance.
|
||||
|
||||
Returns:
|
||||
System prompt that treats `search_documents` as a tool to use when the
|
||||
question is about the user's own documents/data — while still answering
|
||||
general questions directly from the model's own knowledge.
|
||||
"""
|
||||
return f"""{DEFAULT_SYSTEM_PROMPT}
|
||||
|
||||
# Knowledge base
|
||||
You have a `search_documents` tool that searches documents and data the user has added to this workspace.
|
||||
|
||||
When to search:
|
||||
- The question is about the user's own documents, files, policies, projects, or other workspace/organization-specific information.
|
||||
- The user explicitly refers to "the docs", an uploaded file, or internal information.
|
||||
- A factual claim in your answer should be backed by their source material.
|
||||
|
||||
When NOT to search: general knowledge, common concepts, code, math, definitions, or anything you can already answer well. Do not search just to check whether something happens to be in the knowledge base, and never tell the user a topic "isn't in the knowledge base" when it is a question you can simply answer yourself.
|
||||
|
||||
Retrieval budget: start with one focused search using short, distinctive keywords. Search again only if the results miss the core question, a needed fact/figure/owner/date/source is missing, or the user asked for comprehensive coverage or a comparison. Don't search again merely to rephrase or pad the answer.
|
||||
|
||||
Citations: when you use retrieved documents, attach numbered references like [1], [2] to the specific claims they support, and list those sources at the end (filename, plus page if available). Cite only sources that appear in the search results — never fabricate citations, filenames, or page numbers.
|
||||
|
||||
Missing evidence is not automatically a "no". If the documents don't cover the question, say briefly what you couldn't find, then still help: answer from general knowledge where that's appropriate (and note that you're doing so), or ask for the specific document or detail you'd need."""
|
||||
@@ -0,0 +1,413 @@
|
||||
"""PydanticDeep assistant — deep agentic coding assistant.
|
||||
|
||||
PydanticDeep is built on PydanticAI and provides:
|
||||
- Filesystem tools: ls, read_file, write_file, edit_file, glob, grep
|
||||
- Task management: write_todos, planning subagent
|
||||
- Subagent delegation
|
||||
- Skills system (SKILL.md files)
|
||||
- Memory persistence (MEMORY.md across sessions)
|
||||
- Context file discovery (AGENTS.md, SOUL.md from workspace root)
|
||||
- Built-in web search and web fetch
|
||||
|
||||
Backend types (set via PYDANTIC_DEEP_BACKEND_TYPE):
|
||||
"state" — In-memory (default, no persistence)
|
||||
"daytona" — Daytona cloud workspace (isolated, cloud-native)
|
||||
|
||||
Configuration via settings:
|
||||
PYDANTIC_DEEP_BACKEND_TYPE : "state" | "daytona" (default: "state")
|
||||
PYDANTIC_DEEP_INCLUDE_SUBAGENTS : enable subagent delegation (default: True)
|
||||
PYDANTIC_DEEP_INCLUDE_SKILLS : enable skills system (default: True)
|
||||
PYDANTIC_DEEP_INCLUDE_PLAN : enable planner subagent (default: True)
|
||||
PYDANTIC_DEEP_INCLUDE_MEMORY : enable persistent MEMORY.md (default: True)
|
||||
PYDANTIC_DEEP_INCLUDE_EXECUTE : enable shell execution (default: False)
|
||||
PYDANTIC_DEEP_WEB_SEARCH : enable built-in web search (default: True)
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai_backends import BackendProtocol, StateBackend
|
||||
from pydantic_deep import DeepAgentDeps, create_deep_agent
|
||||
|
||||
from app.agents.prompts import get_system_prompt_with_rag
|
||||
from app.agents.tools.antv_chart import get_antv_toolset
|
||||
from app.agents.tools.map_tool import MapMarker, create_map
|
||||
from app.agents.tools.rag_tool import search_knowledge_base
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Map LLM_PROVIDER settings value → pydantic-ai provider prefix
|
||||
_PROVIDER_PREFIXES: dict[str, str] = {
|
||||
"openai": "openai",
|
||||
"anthropic": "anthropic",
|
||||
"google": "google-gla", # Google AI (Gemini) via GOOGLE_API_KEY
|
||||
"openrouter": "openrouter",
|
||||
}
|
||||
|
||||
|
||||
class PydanticDeepContext(TypedDict, total=False):
|
||||
"""Runtime context passed through the agent run."""
|
||||
|
||||
user_id: str | None
|
||||
user_name: str | None
|
||||
metadata: dict[str, Any]
|
||||
|
||||
|
||||
async def _rag_search(query: str, top_k: int = 5) -> str:
|
||||
"""Search the knowledge base for relevant documents.
|
||||
|
||||
Use this tool to find information from uploaded documents before answering
|
||||
user queries. Searches across all available collections automatically.
|
||||
Cite sources by referring to the document filename from the search results.
|
||||
|
||||
Args:
|
||||
query: The search query string.
|
||||
top_k: Number of top results to retrieve (default: 5).
|
||||
|
||||
Returns:
|
||||
Formatted string with search results including content and scores.
|
||||
"""
|
||||
return await search_knowledge_base(query=query, top_k=top_k)
|
||||
|
||||
|
||||
def create_map_tool(
|
||||
title: str,
|
||||
markers: list[MapMarker],
|
||||
center: list[float] | None = None,
|
||||
zoom: int | None = None,
|
||||
) -> str:
|
||||
"""Create an interactive map to show places geographically for the user.
|
||||
|
||||
Use whenever the user asks to show, map, or locate places. Provide
|
||||
latitude/longitude for each marker from your own knowledge (e.g. Warsaw ≈
|
||||
52.23, 21.01). Do not repeat the returned JSON — just briefly describe the
|
||||
map you created.
|
||||
|
||||
Args:
|
||||
title: Short map title.
|
||||
markers: One entry per place, each with lat, lng and a short label
|
||||
(plus optional description and color). Must not be empty.
|
||||
center: Optional [lat, lng] center (auto-fit to markers if omitted).
|
||||
zoom: Optional zoom level 1-18 (mainly useful for a single marker).
|
||||
"""
|
||||
return create_map(
|
||||
title=title,
|
||||
markers=[m.model_dump() for m in markers],
|
||||
center=center,
|
||||
zoom=zoom,
|
||||
)
|
||||
|
||||
|
||||
class PydanticDeepAssistant:
|
||||
"""Deep agentic assistant powered by pydantic-deep.
|
||||
|
||||
Wraps create_deep_agent() with web-app friendly defaults:
|
||||
- StateBackend by default (in-memory, ephemeral)
|
||||
- Optional DaytonaSandbox for cloud-native workspaces
|
||||
- Conversation scoped by conversation_id (history_messages_path)
|
||||
- Non-interactive mode (no human-in-the-loop approval prompts)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_name: str | None = None,
|
||||
thinking_effort: str | None = None,
|
||||
conversation_id: str = "default",
|
||||
user_id: str | None = None,
|
||||
user_name: str | None = None,
|
||||
backend_override: Any = None,
|
||||
history_messages_path: str | None = None,
|
||||
):
|
||||
self.model_name = model_name or settings.AI_MODEL
|
||||
self.thinking_effort = thinking_effort
|
||||
self.conversation_id = conversation_id
|
||||
self.user_id = user_id
|
||||
self.user_name = user_name
|
||||
# Allow project-scoped WS endpoint to inject a pre-built backend/history path
|
||||
self._backend_override = backend_override
|
||||
self._history_messages_path = history_messages_path
|
||||
self._agent: Agent | None = None
|
||||
self._deps: DeepAgentDeps | None = None
|
||||
|
||||
def _get_model_string(self) -> str:
|
||||
"""Build pydantic-ai model string with provider prefix.
|
||||
|
||||
If the model name already contains ':', returns it unchanged.
|
||||
Otherwise prepends the provider prefix from LLM_PROVIDER setting.
|
||||
|
||||
Examples:
|
||||
"gpt-5.5" → "openai:gpt-5.5"
|
||||
"claude-opus-4-7" → "anthropic:claude-opus-4-7"
|
||||
"gemini-2.5-flash" → "google-gla:gemini-2.5-flash"
|
||||
"openai:gpt-5.5" → "openai:gpt-5.5" (unchanged)
|
||||
"""
|
||||
if ":" in self.model_name:
|
||||
return self.model_name
|
||||
prefix = _PROVIDER_PREFIXES.get(settings.LLM_PROVIDER, settings.LLM_PROVIDER)
|
||||
return f"{prefix}:{self.model_name}"
|
||||
|
||||
def _create_backend(self) -> BackendProtocol:
|
||||
"""Create the file-storage backend based on PYDANTIC_DEEP_BACKEND_TYPE."""
|
||||
backend_type = settings.PYDANTIC_DEEP_BACKEND_TYPE
|
||||
|
||||
if backend_type == "daytona":
|
||||
try:
|
||||
from pydantic_ai_backends import DaytonaSandbox
|
||||
|
||||
return DaytonaSandbox(
|
||||
workspace_id=f"pd-{self.conversation_id}",
|
||||
)
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"Daytona backend not available — "
|
||||
"install 'pydantic-ai-backend[daytona]'. Falling back to StateBackend."
|
||||
)
|
||||
return StateBackend()
|
||||
|
||||
# Default: in-memory, no cross-connection persistence
|
||||
return StateBackend()
|
||||
|
||||
def _get_system_prompt(self) -> str:
|
||||
"""Return the base system prompt."""
|
||||
return get_system_prompt_with_rag()
|
||||
|
||||
def _build_agent_and_deps(self) -> tuple[Agent[DeepAgentDeps, str], DeepAgentDeps]:
|
||||
"""Instantiate the pydantic-deep agent and its dependencies."""
|
||||
backend = (
|
||||
self._backend_override if self._backend_override is not None else self._create_backend()
|
||||
)
|
||||
model_str = self._get_model_string()
|
||||
history_path = (
|
||||
self._history_messages_path
|
||||
if self._history_messages_path is not None
|
||||
else f".pydantic-deep/sessions/{self.conversation_id}/messages.json"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Creating PydanticDeep agent — model=%s backend=%s conversation=%s",
|
||||
model_str,
|
||||
type(backend).__name__,
|
||||
self.conversation_id,
|
||||
)
|
||||
# Extra tools exposed as standalone pydantic-ai Tools.
|
||||
# pydantic-deep merges them into the agent automatically.
|
||||
from pydantic_ai import Tool as PAITool
|
||||
|
||||
extra_tools: list[Any] = []
|
||||
extra_tools.append(PAITool(_rag_search))
|
||||
extra_tools.append(PAITool(create_map_tool))
|
||||
|
||||
# Attach the AntV toolset only if this create_deep_agent build accepts a
|
||||
# `toolsets` kwarg — guards against versions that don't expose it.
|
||||
antv_toolset = get_antv_toolset()
|
||||
antv_kwargs: dict[str, Any] = {}
|
||||
if (
|
||||
antv_toolset is not None
|
||||
and "toolsets" in inspect.signature(create_deep_agent).parameters
|
||||
):
|
||||
antv_kwargs["toolsets"] = [antv_toolset]
|
||||
|
||||
agent = create_deep_agent(
|
||||
model=model_str,
|
||||
backend=backend,
|
||||
instructions=self._get_system_prompt(),
|
||||
# Per-conversation history persistence
|
||||
history_messages_path=history_path,
|
||||
# Built-in tool groups
|
||||
include_filesystem=True,
|
||||
include_execute=settings.PYDANTIC_DEEP_INCLUDE_EXECUTE,
|
||||
include_todo=True,
|
||||
include_subagents=settings.PYDANTIC_DEEP_INCLUDE_SUBAGENTS,
|
||||
include_builtin_subagents=settings.PYDANTIC_DEEP_INCLUDE_SUBAGENTS,
|
||||
include_skills=settings.PYDANTIC_DEEP_INCLUDE_SKILLS,
|
||||
include_plan=settings.PYDANTIC_DEEP_INCLUDE_PLAN,
|
||||
# Memory: shared MEMORY.md across all conversations in the workspace
|
||||
include_memory=settings.PYDANTIC_DEEP_INCLUDE_MEMORY,
|
||||
memory_dir=".pydantic-deep",
|
||||
# Context discovery: reads AGENTS.md, SOUL.md from workspace root
|
||||
context_discovery=True,
|
||||
# Web tools (built-in pydantic-ai WebSearch / WebFetch)
|
||||
web_search=settings.PYDANTIC_DEEP_WEB_SEARCH,
|
||||
web_fetch=True,
|
||||
# Context management: auto-compress history at token budget
|
||||
context_manager=True,
|
||||
# Cost tracking
|
||||
cost_tracking=True,
|
||||
# Thinking effort — "auto" lets pydantic-ai resolve per-provider
|
||||
thinking=self.thinking_effort or "auto",
|
||||
# Extra tools (e.g. RAG search)
|
||||
**({"tools": extra_tools} if extra_tools else {}),
|
||||
**antv_kwargs,
|
||||
)
|
||||
|
||||
deps = DeepAgentDeps(backend=backend)
|
||||
return agent, deps
|
||||
|
||||
# Workspace file upload (Docker / Daytona only)
|
||||
|
||||
async def write_file_to_workspace(self, rel_path: str, content: bytes | str) -> bool:
|
||||
"""Write a file into the sandbox workspace (Daytona).
|
||||
|
||||
For StateBackend (in-memory) this is a no-op — callers should fall back
|
||||
to including file content inline in the user message.
|
||||
|
||||
Args:
|
||||
rel_path: Workspace-relative path, e.g. ``uploads/report.pdf``.
|
||||
content: File content (bytes or str).
|
||||
|
||||
Returns:
|
||||
True if the file was written to the sandbox filesystem.
|
||||
"""
|
||||
backend = self.deps.backend
|
||||
data = content if isinstance(content, bytes) else content.encode("utf-8")
|
||||
|
||||
# -- Generic: backend exposes upload_bytes / write_file method ------
|
||||
upload_fn = getattr(backend, "upload_bytes", None) or getattr(backend, "write_file", None)
|
||||
if upload_fn is not None:
|
||||
try:
|
||||
await upload_fn(rel_path, data)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to write %s via backend API: %s", rel_path, exc)
|
||||
return False
|
||||
|
||||
# -- StateBackend: no real filesystem --------------------------------
|
||||
return False
|
||||
|
||||
# Properties: lazy creation of agent + deps on first access
|
||||
|
||||
@property
|
||||
def agent(self) -> Agent:
|
||||
"""Get or create the underlying pydantic-ai Agent."""
|
||||
if self._agent is None:
|
||||
self._agent, self._deps = self._build_agent_and_deps()
|
||||
return self._agent
|
||||
|
||||
@property
|
||||
def deps(self) -> DeepAgentDeps:
|
||||
"""Get or create the DeepAgentDeps for this assistant."""
|
||||
if self._deps is None:
|
||||
self._agent, self._deps = self._build_agent_and_deps()
|
||||
return self._deps
|
||||
|
||||
# Run / stream
|
||||
|
||||
async def run(
|
||||
self,
|
||||
user_input: str,
|
||||
history: list[dict[str, str]] | None = None,
|
||||
context: PydanticDeepContext | None = None,
|
||||
) -> tuple[str, list[Any], PydanticDeepContext]:
|
||||
"""Run the agent and return the full response.
|
||||
|
||||
Note: pydantic-deep manages conversation history internally via the
|
||||
backend (history_messages_path). The ``history`` parameter is accepted
|
||||
for API parity with other agent wrappers but is not used.
|
||||
|
||||
Args:
|
||||
user_input: User's message.
|
||||
history: Ignored — pydantic-deep persists history internally.
|
||||
context: Optional runtime context (user_id, user_name, metadata).
|
||||
|
||||
Returns:
|
||||
Tuple of (output_text, tool_events, context).
|
||||
"""
|
||||
agent_context: PydanticDeepContext = context if context is not None else {}
|
||||
logger.info("Running PydanticDeep agent: %s…", user_input[:100])
|
||||
result = await self.agent.run(user_input, deps=self.deps)
|
||||
|
||||
tool_events: list[Any] = []
|
||||
for message in result.all_messages():
|
||||
if hasattr(message, "parts"):
|
||||
for part in message.parts:
|
||||
if hasattr(part, "tool_name"):
|
||||
tool_events.append(part)
|
||||
|
||||
logger.info("PydanticDeep run complete. Output: %d chars", len(result.output))
|
||||
return result.output, tool_events, agent_context
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
user_input: str,
|
||||
history: list[dict[str, str]] | None = None,
|
||||
context: PydanticDeepContext | None = None,
|
||||
):
|
||||
"""Stream agent execution token by token.
|
||||
|
||||
Note: pydantic-deep manages conversation history internally via the
|
||||
backend (history_messages_path). The ``history`` parameter is accepted
|
||||
for API parity with other agent wrappers but is not used.
|
||||
|
||||
Args:
|
||||
user_input: User's message.
|
||||
history: Ignored — pydantic-deep persists history internally.
|
||||
context: Optional runtime context.
|
||||
|
||||
Yields:
|
||||
Tuples of ("messages", (chunk, metadata)) for LLM tokens.
|
||||
"""
|
||||
logger.info("Streaming PydanticDeep agent: %s…", user_input[:100])
|
||||
async with self.agent.run_stream(user_input, deps=self.deps) as response:
|
||||
async for text in response.stream_text(delta=True):
|
||||
yield "messages", (text, {})
|
||||
|
||||
|
||||
# Factory helpers
|
||||
|
||||
|
||||
def get_agent(
|
||||
model_name: str | None = None,
|
||||
thinking_effort: str | None = None,
|
||||
conversation_id: str = "default",
|
||||
user_id: str | None = None,
|
||||
user_name: str | None = None,
|
||||
backend_override: Any = None,
|
||||
history_messages_path: str | None = None,
|
||||
) -> PydanticDeepAssistant:
|
||||
"""Create a PydanticDeepAssistant instance.
|
||||
|
||||
Args:
|
||||
model_name: Override AI_MODEL from settings.
|
||||
thinking_effort: Extended thinking effort level (e.g. "low", "medium", "high").
|
||||
conversation_id: Scope history to this conversation (default: "default").
|
||||
user_id: Optional user identifier for context.
|
||||
user_name: Optional user display name for context.
|
||||
backend_override: Pre-built backend (e.g. DaytonaSandbox for a project).
|
||||
When provided, bypasses _create_backend() entirely.
|
||||
history_messages_path: Override the history file path inside the backend.
|
||||
Useful for project-scoped chats where each chat has its own path.
|
||||
|
||||
Returns:
|
||||
Configured PydanticDeepAssistant.
|
||||
"""
|
||||
return PydanticDeepAssistant(
|
||||
model_name=model_name,
|
||||
thinking_effort=thinking_effort,
|
||||
conversation_id=conversation_id,
|
||||
user_id=user_id,
|
||||
user_name=user_name,
|
||||
backend_override=backend_override,
|
||||
history_messages_path=history_messages_path,
|
||||
)
|
||||
|
||||
|
||||
async def run_agent(
|
||||
user_input: str,
|
||||
conversation_id: str = "default",
|
||||
context: PydanticDeepContext | None = None,
|
||||
) -> tuple[str, list[Any], PydanticDeepContext]:
|
||||
"""One-shot convenience wrapper for running the agent.
|
||||
|
||||
Args:
|
||||
user_input: User's message.
|
||||
conversation_id: Conversation scope for history.
|
||||
context: Optional runtime context.
|
||||
|
||||
Returns:
|
||||
Tuple of (output_text, tool_events, context).
|
||||
"""
|
||||
assistant = get_agent(conversation_id=conversation_id)
|
||||
return await assistant.run(user_input, context)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Agent tools module.
|
||||
|
||||
This module contains utility functions that can be used as agent tools.
|
||||
Tools are registered in the agent definition using @agent.tool decorator.
|
||||
"""
|
||||
|
||||
from app.agents.tools.datetime_tool import get_current_datetime
|
||||
from app.agents.tools.map_tool import create_map
|
||||
from app.agents.tools.rag_tool import search_knowledge_base, search_knowledge_base_sync
|
||||
from app.agents.tools.web_search import parse_web_search, web_search, web_search_sync
|
||||
|
||||
__all__ = ["get_current_datetime"]
|
||||
__all__ += ["parse_web_search", "web_search", "web_search_sync"]
|
||||
__all__ += ["search_knowledge_base", "search_knowledge_base_sync"]
|
||||
__all__ += ["create_map"]
|
||||
@@ -0,0 +1,35 @@
|
||||
"""AntV chart MCP server integration.
|
||||
|
||||
The AntV ``mcp-server-chart`` sidecar exposes advanced diagram tools (flowchart,
|
||||
mind-map, org-chart, sankey, fishbone, network, treemap, radar, funnel, ...)
|
||||
over MCP (streamable HTTP). Each AI framework attaches MCP differently, so this
|
||||
module exposes one loader per framework family.
|
||||
|
||||
All loaders no-op (return ``None`` / ``[]``) when ``ENABLE_ANTV_CHARTS`` is
|
||||
false, and degrade gracefully (log a warning + return empty) if the sidecar or
|
||||
adapter is unavailable — so the agent always starts, with or without AntV.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_antv_toolset() -> Any | None:
|
||||
"""Return a PydanticAI MCP toolset for the AntV server, or None if disabled.
|
||||
|
||||
PydanticAI connects lazily when the agent runs, so nothing async happens
|
||||
here — the toolset is simply handed to ``Agent(toolsets=[...])``.
|
||||
"""
|
||||
if not settings.ENABLE_ANTV_CHARTS:
|
||||
return None
|
||||
try:
|
||||
from pydantic_ai.mcp import MCPServerStreamableHTTP
|
||||
|
||||
return MCPServerStreamableHTTP(settings.ANTV_MCP_URL)
|
||||
except Exception as exc:
|
||||
logger.warning("AntV MCP toolset unavailable, continuing without it: %s", exc)
|
||||
return None
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Date and time utilities for agents."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
|
||||
def get_current_datetime() -> dict[str, str]:
|
||||
"""Get the current date and time (UTC)."""
|
||||
now = datetime.now(UTC)
|
||||
return {
|
||||
"date": now.strftime("%Y-%m-%d"),
|
||||
"time": now.strftime("%H:%M:%S"),
|
||||
"datetime": now.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Map-generation tool for agents.
|
||||
|
||||
Mirrors ``chart_tool``: the agent calls ``create_map`` with marker coordinates
|
||||
and the tool returns a structured ``MapSpec`` as a JSON string. The web frontend
|
||||
parses it and renders an interactive map with Leaflet + OpenStreetMap tiles
|
||||
(global coverage, no API key). The model supplies latitude/longitude for known
|
||||
places directly from its own knowledge.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError, field_validator
|
||||
|
||||
MAX_MARKERS = 100
|
||||
|
||||
|
||||
class MapMarker(BaseModel):
|
||||
"""A single point on the map."""
|
||||
|
||||
lat: float = Field(ge=-90, le=90, description="Latitude in decimal degrees.")
|
||||
lng: float = Field(ge=-180, le=180, description="Longitude in decimal degrees.")
|
||||
label: str = Field(max_length=120, description="Short marker label.")
|
||||
description: str | None = Field(
|
||||
default=None, max_length=300, description="Optional detail shown on click."
|
||||
)
|
||||
color: str | None = Field(default=None, description="Hex color override, e.g. '#ef4444'.")
|
||||
|
||||
|
||||
class MapSpec(BaseModel):
|
||||
"""Canonical map payload produced by the tool and rendered by the frontend."""
|
||||
|
||||
kind: str = Field(default="map")
|
||||
title: str = Field(max_length=200)
|
||||
markers: list[MapMarker]
|
||||
center: list[float] | None = Field(
|
||||
default=None, description="Optional [lat, lng] center; auto-fit to markers if omitted."
|
||||
)
|
||||
zoom: int | None = Field(default=None, ge=1, le=18, description="Optional zoom (1-18).")
|
||||
|
||||
@field_validator("markers")
|
||||
@classmethod
|
||||
def _validate_markers(cls, v: list[MapMarker]) -> list[MapMarker]:
|
||||
"""Require at least one marker and cap the total."""
|
||||
if not v:
|
||||
raise ValueError("markers must contain at least one point")
|
||||
if len(v) > MAX_MARKERS:
|
||||
raise ValueError(f"too many markers (max {MAX_MARKERS})")
|
||||
return v
|
||||
|
||||
@field_validator("center")
|
||||
@classmethod
|
||||
def _validate_center(cls, v: list[float] | None) -> list[float] | None:
|
||||
"""Require center, when given, to be an in-range [lat, lng] pair."""
|
||||
if v is None:
|
||||
return v
|
||||
if len(v) != 2:
|
||||
raise ValueError("center must be [lat, lng]")
|
||||
lat, lng = v
|
||||
if not (-90 <= lat <= 90) or not (-180 <= lng <= 180):
|
||||
raise ValueError("center out of range (lat must be -90..90, lng -180..180)")
|
||||
return v
|
||||
|
||||
|
||||
def create_map(
|
||||
title: str,
|
||||
markers: list[dict[str, Any]],
|
||||
center: list[float] | None = None,
|
||||
zoom: int | None = None,
|
||||
) -> str:
|
||||
"""Create an interactive map for the user.
|
||||
|
||||
Use this whenever the user asks to show, map, or locate places
|
||||
geographically. Provide latitude/longitude for each marker from your own
|
||||
knowledge (e.g. Warsaw ≈ 52.23, 21.01). The map renders interactively in the
|
||||
web chat with OpenStreetMap tiles.
|
||||
|
||||
Args:
|
||||
title: Short title shown above the map.
|
||||
markers: List of {"lat", "lng", "label", "description"?, "color"?}.
|
||||
center: Optional [lat, lng] center. If omitted, the map auto-fits to the
|
||||
markers.
|
||||
zoom: Optional zoom level (1-18). Mainly useful with a single marker.
|
||||
|
||||
Returns:
|
||||
A JSON string with the map specification. Do not repeat this JSON back to
|
||||
the user — just briefly describe the map you created.
|
||||
"""
|
||||
try:
|
||||
spec = MapSpec(
|
||||
title=title,
|
||||
markers=[MapMarker(**m) for m in markers],
|
||||
center=center,
|
||||
zoom=zoom,
|
||||
)
|
||||
except ValidationError as e:
|
||||
return f"Could not build map — invalid arguments: {e.errors()}"
|
||||
except (TypeError, ValueError) as e:
|
||||
return f"Could not build map: {e}"
|
||||
|
||||
return spec.model_dump_json()
|
||||
@@ -0,0 +1,145 @@
|
||||
"""RAG tool for agent knowledge base search."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.services.rag.retrieval import BaseRetrievalService
|
||||
|
||||
_retrieval_service: "BaseRetrievalService | None" = None
|
||||
|
||||
|
||||
def _get_retrieval_service() -> "BaseRetrievalService":
|
||||
"""Get or create retrieval service singleton."""
|
||||
global _retrieval_service
|
||||
if _retrieval_service is not None:
|
||||
return _retrieval_service
|
||||
from app.core.config import settings
|
||||
from app.services.rag.embeddings import EmbeddingService
|
||||
from app.services.rag.retrieval import RetrievalService
|
||||
from app.services.rag.vectorstore import QdrantVectorStore
|
||||
|
||||
rag_settings = settings.rag
|
||||
embedding_service = EmbeddingService(rag_settings)
|
||||
vector_store = QdrantVectorStore(rag_settings, embedding_service)
|
||||
_retrieval_service = RetrievalService(vector_store, rag_settings)
|
||||
return _retrieval_service
|
||||
|
||||
|
||||
def get_retrieval_service() -> "BaseRetrievalService":
|
||||
"""Get the RetrievalService singleton."""
|
||||
return _get_retrieval_service()
|
||||
|
||||
|
||||
def _format_results(results: list) -> str:
|
||||
if not results:
|
||||
return "No relevant documents found in the knowledge base."
|
||||
formatted = []
|
||||
for i, result in enumerate(results, start=1):
|
||||
source = result.metadata.get("filename", "unknown")
|
||||
page = result.metadata.get("page_num", "")
|
||||
chunk = result.metadata.get("chunk_num", "")
|
||||
col = result.metadata.get("collection", "")
|
||||
page_info = f", page {page}" if page else ""
|
||||
chunk_info = f", chunk {chunk}" if chunk else ""
|
||||
col_info = f" [{col}]" if col else ""
|
||||
formatted.append(
|
||||
f"[{i}] Source: {source}{page_info}{chunk_info}{col_info} (score: {result.score:.3f})\n"
|
||||
f"{result.content}"
|
||||
)
|
||||
return "Search results (cite sources using [1], [2], etc. in your response):\n\n" + "\n\n".join(
|
||||
formatted
|
||||
)
|
||||
|
||||
|
||||
async def search_knowledge_base(
|
||||
query: str,
|
||||
collection: str | None = None,
|
||||
collections: list[str] | None = None,
|
||||
top_k: int = 5,
|
||||
) -> str:
|
||||
"""Search the knowledge base and return formatted results.
|
||||
|
||||
Args:
|
||||
query: The search query string.
|
||||
collection: Name of a single collection. If None, uses RAG_DEFAULT_COLLECTION env var.
|
||||
collections: List of collection names for cross-collection search (overrides collection).
|
||||
top_k: Number of top results to retrieve (default: 5).
|
||||
"""
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
service: Any = get_retrieval_service()
|
||||
|
||||
default_collection = os.environ.get("RAG_DEFAULT_COLLECTION", "all")
|
||||
target_collection = collection or default_collection
|
||||
|
||||
if collections and len(collections) > 1:
|
||||
results = await service.retrieve_multi(
|
||||
query=query,
|
||||
collection_names=collections,
|
||||
limit=top_k,
|
||||
)
|
||||
elif target_collection == "all":
|
||||
try:
|
||||
all_collections = await service.store.list_collections()
|
||||
if not all_collections:
|
||||
return "No collections found in the knowledge base."
|
||||
if len(all_collections) == 1:
|
||||
results = await service.retrieve(
|
||||
query=query, collection_name=all_collections[0], limit=top_k
|
||||
)
|
||||
else:
|
||||
results = await service.retrieve_multi(
|
||||
query=query, collection_names=all_collections, limit=top_k
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list collections: {e}")
|
||||
return f"Error accessing knowledge base: {e}"
|
||||
else:
|
||||
results = await service.retrieve(
|
||||
query=query,
|
||||
collection_name=target_collection,
|
||||
limit=top_k,
|
||||
)
|
||||
|
||||
return _format_results(results)
|
||||
|
||||
|
||||
def _run_async_search(query: str, collection: str | None, top_k: int) -> str:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
return loop.run_until_complete(search_knowledge_base(query, collection, top_k=top_k))
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
def search_knowledge_base_sync(
|
||||
query: str,
|
||||
collection: str | None = None,
|
||||
top_k: int = 5,
|
||||
) -> str:
|
||||
"""Synchronous wrapper for search_knowledge_base. Use in CrewAI agents."""
|
||||
logger.debug(
|
||||
"search_knowledge_base_sync called: query=%s, collection=%s, top_k=%s",
|
||||
query,
|
||||
collection,
|
||||
top_k,
|
||||
)
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
future = executor.submit(_run_async_search, query, collection, top_k)
|
||||
result = future.result()
|
||||
logger.debug("search_knowledge_base_sync completed successfully")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error("search_knowledge_base_sync failed: %s", str(e), exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
__all__ = ["search_knowledge_base", "search_knowledge_base_sync"]
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Web search tool using Tavily API.
|
||||
|
||||
Provides AI agents with the ability to search the web for current information.
|
||||
Requires TAVILY_API_KEY environment variable.
|
||||
|
||||
Get your API key at: https://tavily.com
|
||||
|
||||
The tool returns a JSON ``WebSearchResults`` payload (not prose) so the web
|
||||
frontend can render results richly — clickable titles, domains, snippets —
|
||||
the same way the chart tool returns a structured spec. Modern LLMs read JSON
|
||||
tool results fine and can still cite by title/URL.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class WebSearchResult(BaseModel):
|
||||
"""A single web search hit."""
|
||||
|
||||
title: str
|
||||
url: str
|
||||
content: str
|
||||
score: float | None = None
|
||||
|
||||
|
||||
class WebSearchResults(BaseModel):
|
||||
"""Structured web search payload returned by the ``web_search`` tool."""
|
||||
|
||||
kind: Literal["web_search"] = "web_search"
|
||||
query: str
|
||||
results: list[WebSearchResult] = Field(default_factory=list)
|
||||
|
||||
|
||||
async def web_search(
|
||||
query: str,
|
||||
max_results: int = 5,
|
||||
search_depth: str = "basic",
|
||||
) -> str:
|
||||
"""Search the web for current information using Tavily.
|
||||
|
||||
Args:
|
||||
query: The search query string.
|
||||
max_results: Maximum number of results to return (1-10, default: 5).
|
||||
search_depth: Search depth - "basic" (fast) or "advanced" (thorough).
|
||||
|
||||
Returns:
|
||||
A JSON string with the structured results (a ``WebSearchResults``
|
||||
payload). Use the titles/URLs to cite sources in your answer; do not
|
||||
repeat the raw JSON back to the user.
|
||||
"""
|
||||
try:
|
||||
from tavily import AsyncTavilyClient
|
||||
except ImportError:
|
||||
return "Web search unavailable: the 'tavily' package is not installed."
|
||||
|
||||
try:
|
||||
client = AsyncTavilyClient(api_key=settings.TAVILY_API_KEY)
|
||||
response = await client.search(
|
||||
query=query,
|
||||
max_results=min(max_results, 10),
|
||||
search_depth=search_depth,
|
||||
)
|
||||
except Exception as e:
|
||||
# Surface a usable message to the agent instead of crashing the turn.
|
||||
return f"Web search failed: {e}"
|
||||
|
||||
results = [
|
||||
WebSearchResult(
|
||||
title=r.get("title") or "Untitled",
|
||||
url=r.get("url") or "",
|
||||
content=(r.get("content") or "")[:1000],
|
||||
score=r.get("score"),
|
||||
)
|
||||
for r in response.get("results", [])
|
||||
]
|
||||
return WebSearchResults(query=query, results=results).model_dump_json()
|
||||
|
||||
|
||||
def web_search_sync(
|
||||
query: str,
|
||||
max_results: int = 5,
|
||||
search_depth: str = "basic",
|
||||
) -> str:
|
||||
"""Synchronous wrapper for web_search (for CrewAI/LangChain sync tools)."""
|
||||
import asyncio
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
return loop.run_until_complete(web_search(query, max_results, search_depth))
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
def parse_web_search(result: str) -> WebSearchResults | None:
|
||||
"""Parse a ``web_search`` tool result back into a model.
|
||||
|
||||
Returns None when the result is an error/plain string rather than a
|
||||
structured payload (the frontend/channel layers fall back to plain text).
|
||||
"""
|
||||
try:
|
||||
payload = json.loads(result)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
if not isinstance(payload, dict) or payload.get("kind") != "web_search":
|
||||
return None
|
||||
try:
|
||||
return WebSearchResults.model_validate(payload)
|
||||
except ValidationError:
|
||||
return None
|
||||
@@ -0,0 +1,451 @@
|
||||
"""API dependencies.
|
||||
|
||||
Dependency injection factories for services, repositories, and authentication.
|
||||
"""
|
||||
# ruff: noqa: I001 - Imports structured for Jinja2 template conditionals
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.session import get_db_session
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
DBSession = Annotated[AsyncSession, Depends(get_db_session)]
|
||||
from fastapi import Request
|
||||
|
||||
from app.clients.redis import RedisClient
|
||||
|
||||
|
||||
async def get_redis(request: Request) -> RedisClient:
|
||||
"""Get Redis client from lifespan state."""
|
||||
return request.state.redis # type: ignore[no-any-return]
|
||||
|
||||
|
||||
Redis = Annotated[RedisClient, Depends(get_redis)]
|
||||
|
||||
|
||||
# === Service Dependencies ===
|
||||
|
||||
from app.services.user import UserService
|
||||
from app.services.webhook import WebhookService
|
||||
from app.services.conversation import ConversationService
|
||||
|
||||
|
||||
def get_user_service(db: DBSession) -> UserService:
|
||||
"""Create UserService instance with database session."""
|
||||
return UserService(db)
|
||||
|
||||
|
||||
UserSvc = Annotated[UserService, Depends(get_user_service)]
|
||||
|
||||
|
||||
def get_webhook_service(db: DBSession) -> WebhookService:
|
||||
"""Create WebhookService instance with database session."""
|
||||
return WebhookService(db)
|
||||
|
||||
|
||||
WebhookSvc = Annotated[WebhookService, Depends(get_webhook_service)]
|
||||
|
||||
|
||||
def get_conversation_service(db: DBSession) -> ConversationService:
|
||||
"""Create ConversationService instance with database session."""
|
||||
return ConversationService(db)
|
||||
|
||||
|
||||
ConversationSvc = Annotated[ConversationService, Depends(get_conversation_service)]
|
||||
|
||||
from app.services.conversation_share import ConversationShareService
|
||||
|
||||
|
||||
def get_conversation_share_service(db: DBSession) -> ConversationShareService:
|
||||
"""Create ConversationShareService instance with database session."""
|
||||
return ConversationShareService(db)
|
||||
|
||||
|
||||
ConversationShareSvc = Annotated[ConversationShareService, Depends(get_conversation_share_service)]
|
||||
from app.services.project import ProjectService
|
||||
|
||||
|
||||
def get_project_service(db: DBSession) -> ProjectService:
|
||||
"""Create ProjectService instance with database session."""
|
||||
return ProjectService(db)
|
||||
|
||||
|
||||
ProjectSvc = Annotated[ProjectService, Depends(get_project_service)]
|
||||
|
||||
# Message rating service
|
||||
from app.services.message_rating import MessageRatingService
|
||||
|
||||
|
||||
def get_rating_service(db: DBSession) -> MessageRatingService:
|
||||
"""Create MessageRatingService instance with database session."""
|
||||
return MessageRatingService(db)
|
||||
|
||||
|
||||
MessageRatingSvc = Annotated[MessageRatingService, Depends(get_rating_service)]
|
||||
from app.services.rag_document import RAGDocumentService
|
||||
from app.services.rag_sync import RAGSyncService
|
||||
from app.services.sync_source import SyncSourceService
|
||||
|
||||
|
||||
def get_rag_document_service(db: DBSession) -> RAGDocumentService:
|
||||
"""Create RAGDocumentService instance with database session."""
|
||||
return RAGDocumentService(db)
|
||||
|
||||
|
||||
def get_rag_sync_service(db: DBSession) -> RAGSyncService:
|
||||
"""Create RAGSyncService instance with database session."""
|
||||
return RAGSyncService(db)
|
||||
|
||||
|
||||
def get_sync_source_service(db: DBSession) -> SyncSourceService:
|
||||
"""Create SyncSourceService instance with database session."""
|
||||
return SyncSourceService(db)
|
||||
|
||||
|
||||
RAGDocumentSvc = Annotated[RAGDocumentService, Depends(get_rag_document_service)]
|
||||
RAGSyncSvc = Annotated[RAGSyncService, Depends(get_rag_sync_service)]
|
||||
SyncSourceSvc = Annotated[SyncSourceService, Depends(get_sync_source_service)]
|
||||
from app.services.rag_status import RAGStatusService
|
||||
|
||||
|
||||
def get_rag_status_service() -> RAGStatusService:
|
||||
"""Create RAGStatusService instance (no DB)."""
|
||||
return RAGStatusService()
|
||||
|
||||
|
||||
RAGStatusSvc = Annotated[RAGStatusService, Depends(get_rag_status_service)]
|
||||
from app.services.file_upload import FileUploadService
|
||||
|
||||
|
||||
def get_file_upload_service(db: DBSession) -> FileUploadService:
|
||||
"""Create FileUploadService instance with database session."""
|
||||
return FileUploadService(db)
|
||||
|
||||
|
||||
FileUploadSvc = Annotated[FileUploadService, Depends(get_file_upload_service)]
|
||||
# === Authentication Dependencies ===
|
||||
|
||||
from app.core.exceptions import AuthenticationError, AuthorizationError
|
||||
from app.db.models.user import User, UserRole
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login")
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: Annotated[str, Depends(oauth2_scheme)],
|
||||
user_service: UserSvc,
|
||||
) -> User:
|
||||
"""Get current authenticated user from JWT token.
|
||||
|
||||
Returns the full User object including role information.
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If token is invalid or user not found.
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
from app.core.security import verify_token
|
||||
|
||||
payload = verify_token(token)
|
||||
if payload is None:
|
||||
raise AuthenticationError(message="Invalid or expired token")
|
||||
|
||||
# Ensure this is an access token, not a refresh token
|
||||
if payload.get("type") != "access":
|
||||
raise AuthenticationError(message="Invalid token type")
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise AuthenticationError(message="Invalid token payload")
|
||||
|
||||
user = await user_service.get_by_id(UUID(user_id))
|
||||
if not user.is_active:
|
||||
raise AuthenticationError(message="User account is disabled")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
class RoleChecker:
|
||||
"""Dependency class for role-based access control.
|
||||
|
||||
Usage:
|
||||
# Require admin role
|
||||
@router.get("/admin-only")
|
||||
async def admin_endpoint(
|
||||
user: Annotated[User, Depends(RoleChecker(UserRole.ADMIN))]
|
||||
):
|
||||
...
|
||||
|
||||
# Require any authenticated user
|
||||
@router.get("/users")
|
||||
async def users_endpoint(
|
||||
user: Annotated[User, Depends(get_current_user)]
|
||||
):
|
||||
...
|
||||
"""
|
||||
|
||||
def __init__(self, required_role: UserRole) -> None:
|
||||
self.required_role = required_role
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
) -> User:
|
||||
"""Check if user has the required role.
|
||||
|
||||
Raises:
|
||||
AuthorizationError: If user doesn't have the required role.
|
||||
"""
|
||||
if not user.has_role(self.required_role):
|
||||
raise AuthorizationError(
|
||||
message=f"Role '{self.required_role.value}' required for this action"
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_active_superuser(
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> User:
|
||||
"""Get current user and verify they are a superuser.
|
||||
|
||||
Raises:
|
||||
AuthorizationError: If user is not a superuser.
|
||||
"""
|
||||
if not current_user.has_role(UserRole.ADMIN):
|
||||
raise AuthorizationError(message="Admin privileges required")
|
||||
return current_user
|
||||
|
||||
|
||||
# Type aliases for dependency injection
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
CurrentSuperuser = Annotated[User, Depends(get_current_active_superuser)]
|
||||
CurrentAdmin = Annotated[User, Depends(RoleChecker(UserRole.ADMIN))]
|
||||
|
||||
|
||||
# is_app_admin is a global flag on the User model — independent of team
|
||||
# membership. Routes guarded by this dep (e.g. /admin/users) stay reachable
|
||||
# even when teams are disabled, so the dep itself must not be gated.
|
||||
async def _require_app_admin(user: CurrentUser) -> "User": # type: ignore[name-defined]
|
||||
"""Raises 403 unless the user has the is_app_admin flag set."""
|
||||
if not getattr(user, "is_app_admin", False):
|
||||
raise AuthorizationError(message="App admin privileges required")
|
||||
return user
|
||||
|
||||
|
||||
CurrentAppAdmin = Annotated["User", Depends(_require_app_admin)] # type: ignore[valid-type]
|
||||
|
||||
|
||||
# WebSocket authentication dependency
|
||||
from fastapi import WebSocket, Cookie
|
||||
|
||||
|
||||
_WS_TOKEN_PROTOCOL_PREFIX = "access_token."
|
||||
|
||||
|
||||
def _extract_ws_auth(websocket: WebSocket) -> tuple[str | None, str | None]:
|
||||
"""Parse Sec-WebSocket-Protocol header for an auth token + app subprotocol.
|
||||
|
||||
Clients pass the token as a subprotocol of the form
|
||||
``access_token.<JWT>`` alongside an optional application subprotocol
|
||||
(e.g. ``chat``). Returns (token, app_subprotocol) — either may be None.
|
||||
"""
|
||||
raw = websocket.headers.get("sec-websocket-protocol") or ""
|
||||
token: str | None = None
|
||||
app_subprotocol: str | None = None
|
||||
for proto in (p.strip() for p in raw.split(",") if p.strip()):
|
||||
if proto.startswith(_WS_TOKEN_PROTOCOL_PREFIX):
|
||||
token = proto[len(_WS_TOKEN_PROTOCOL_PREFIX) :]
|
||||
elif app_subprotocol is None:
|
||||
app_subprotocol = proto
|
||||
return token, app_subprotocol
|
||||
|
||||
|
||||
async def get_current_user_ws(
|
||||
websocket: WebSocket,
|
||||
access_token: str | None = Cookie(None),
|
||||
) -> User:
|
||||
"""Authenticate a WebSocket connection.
|
||||
|
||||
Token sources, checked in order:
|
||||
1. ``Sec-WebSocket-Protocol`` header, in the form ``access_token.<JWT>``.
|
||||
The chosen application subprotocol (e.g. ``chat``) is echoed back on
|
||||
``accept()`` via ``websocket.state.accept_subprotocol``.
|
||||
2. Same-origin ``access_token`` cookie (fallback for same-origin clients).
|
||||
|
||||
Tokens in query strings are NOT accepted — they leak into logs and
|
||||
Referer headers.
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If token is invalid or user not found.
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
from app.core.security import verify_token
|
||||
|
||||
subprotocol_token, app_subprotocol = _extract_ws_auth(websocket)
|
||||
websocket.state.accept_subprotocol = app_subprotocol
|
||||
|
||||
auth_token = subprotocol_token or access_token
|
||||
|
||||
if not auth_token:
|
||||
await websocket.close(code=4001, reason="Missing authentication token")
|
||||
raise AuthenticationError(message="Missing authentication token")
|
||||
|
||||
payload = verify_token(auth_token)
|
||||
if payload is None:
|
||||
await websocket.close(code=4001, reason="Invalid or expired token")
|
||||
raise AuthenticationError(message="Invalid or expired token")
|
||||
|
||||
if payload.get("type") != "access":
|
||||
await websocket.close(code=4001, reason="Invalid token type")
|
||||
raise AuthenticationError(message="Invalid token type")
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
await websocket.close(code=4001, reason="Invalid token payload")
|
||||
raise AuthenticationError(message="Invalid token payload")
|
||||
|
||||
from app.db.session import get_db_context
|
||||
|
||||
async with get_db_context() as db:
|
||||
user_service = UserService(db)
|
||||
user = await user_service.get_by_id(UUID(user_id))
|
||||
|
||||
if not user.is_active:
|
||||
await websocket.close(code=4001, reason="User account is disabled")
|
||||
raise AuthenticationError(message="User account is disabled")
|
||||
|
||||
# Eagerly load all columns, then detach from session to avoid
|
||||
# "instance not bound to a Session" errors after the context manager exits
|
||||
await db.refresh(user)
|
||||
db.expunge(user)
|
||||
return user
|
||||
|
||||
|
||||
import secrets
|
||||
|
||||
from fastapi.security import APIKeyHeader
|
||||
|
||||
|
||||
api_key_header = APIKeyHeader(name=settings.API_KEY_HEADER, auto_error=False)
|
||||
|
||||
|
||||
async def verify_api_key(
|
||||
api_key: Annotated[str | None, Depends(api_key_header)],
|
||||
) -> str:
|
||||
"""Verify API key from header.
|
||||
|
||||
Uses constant-time comparison to prevent timing attacks.
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If API key is missing.
|
||||
AuthorizationError: If API key is invalid.
|
||||
"""
|
||||
if api_key is None:
|
||||
raise AuthenticationError(message="API Key header missing")
|
||||
if not secrets.compare_digest(api_key, settings.API_KEY):
|
||||
raise AuthorizationError(message="Invalid API Key")
|
||||
return api_key
|
||||
|
||||
|
||||
ValidAPIKey = Annotated[str, Depends(verify_api_key)]
|
||||
|
||||
# === RAG Service Dependencies ===
|
||||
|
||||
from app.services.rag.embeddings import EmbeddingService
|
||||
from app.services.rag.ingestion import IngestionService
|
||||
from app.services.rag.documents import DocumentProcessor
|
||||
from fastapi import Request
|
||||
from app.core.config import settings
|
||||
from app.services.rag.retrieval import RetrievalService
|
||||
from app.services.rag.vectorstore import QdrantVectorStore
|
||||
|
||||
|
||||
def get_embedding_service(request: Request) -> EmbeddingService:
|
||||
"""Get embedding service from lifespan state or create new if not available."""
|
||||
if request and hasattr(request.state, "embedding_service"):
|
||||
return request.state.embedding_service # type: ignore[no-any-return]
|
||||
return EmbeddingService(settings=settings.rag)
|
||||
|
||||
|
||||
# Type Alias for the Embedder
|
||||
EmbeddingSvc = Annotated[EmbeddingService, Depends(get_embedding_service)]
|
||||
|
||||
from app.services.rag.vectorstore import BaseVectorStore
|
||||
|
||||
|
||||
def get_vectorstore(request: Request, embedder: EmbeddingSvc) -> BaseVectorStore:
|
||||
"""Get vector store client from lifespan state or create new."""
|
||||
if request and hasattr(request.state, "vector_store"):
|
||||
return request.state.vector_store # type: ignore[no-any-return]
|
||||
return QdrantVectorStore(settings=settings.rag, embedding_service=embedder)
|
||||
|
||||
|
||||
VectorStoreSvc = Annotated[BaseVectorStore, Depends(get_vectorstore)]
|
||||
|
||||
|
||||
def get_retrieval_service(vector_store: VectorStoreSvc) -> RetrievalService:
|
||||
"""Create RetrievalService instance."""
|
||||
from app.services.rag.reranker import RerankService
|
||||
|
||||
rerank_service = RerankService(settings=settings.rag)
|
||||
return RetrievalService(
|
||||
vector_store=vector_store,
|
||||
settings=settings.rag,
|
||||
rerank_service=rerank_service,
|
||||
)
|
||||
|
||||
|
||||
RetrievalSvc = Annotated[RetrievalService, Depends(get_retrieval_service)]
|
||||
|
||||
|
||||
def get_document_processor() -> DocumentProcessor:
|
||||
"""Create DocumentProcessor instance."""
|
||||
return DocumentProcessor(settings=settings.rag)
|
||||
|
||||
|
||||
DocumentProcessorSvc = Annotated[DocumentProcessor, Depends(get_document_processor)]
|
||||
|
||||
|
||||
def get_ingestion_service(
|
||||
processor: DocumentProcessorSvc,
|
||||
vector_store: VectorStoreSvc,
|
||||
request: Request,
|
||||
) -> IngestionService:
|
||||
"""Create IngestionService instance."""
|
||||
|
||||
# Wire webhook dispatch for RAG events
|
||||
async def on_rag_event(event: str, data: dict):
|
||||
from app.services.webhook import WebhookService
|
||||
|
||||
db = request.state.db if hasattr(request.state, "db") else None
|
||||
if db:
|
||||
webhook_service = WebhookService(db)
|
||||
await webhook_service.dispatch_event(event, data)
|
||||
|
||||
return IngestionService(processor=processor, vector_store=vector_store, on_event=on_rag_event)
|
||||
|
||||
|
||||
IngestionSvc = Annotated[IngestionService, Depends(get_ingestion_service)]
|
||||
from app.services.user_slash_command import UserSlashCommandService
|
||||
|
||||
|
||||
def get_user_slash_command_service(db: DBSession) -> UserSlashCommandService:
|
||||
return UserSlashCommandService(db)
|
||||
|
||||
|
||||
UserSlashCommandSvc = Annotated[UserSlashCommandService, Depends(get_user_slash_command_service)]
|
||||
from app.services.admin import AdminService
|
||||
|
||||
|
||||
def get_admin_service(db: DBSession) -> AdminService:
|
||||
"""Create AdminService instance — used by admin REST routes (always
|
||||
available, independent of the optional SQLAdmin UI)."""
|
||||
return AdminService(db)
|
||||
|
||||
|
||||
AdminSvc = Annotated[AdminService, Depends(get_admin_service)]
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Exception handlers for FastAPI application.
|
||||
|
||||
These handlers convert domain exceptions to proper HTTP responses.
|
||||
WebSocket connections that raise an ``AppException`` before ``accept()`` are
|
||||
handled too — Starlette closes the socket with 403 and we just log the
|
||||
incident; we cannot return an HTTP body for a non-HTTP scope.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.requests import HTTPConnection
|
||||
|
||||
from app.core.exceptions import AppException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _connection_meta(conn: HTTPConnection) -> dict[str, Any]:
|
||||
"""Common log fields shared by HTTP requests and WebSocket connections.
|
||||
|
||||
``method`` exists only on HTTP ``Request`` — for WebSockets we surface the
|
||||
scope type so log filters can still distinguish the two.
|
||||
"""
|
||||
return {
|
||||
"path": conn.url.path,
|
||||
"method": getattr(conn, "method", None) or conn.scope.get("type", "unknown"),
|
||||
}
|
||||
|
||||
|
||||
def _is_websocket(conn: HTTPConnection) -> bool:
|
||||
return conn.scope.get("type") == "websocket"
|
||||
|
||||
|
||||
async def app_exception_handler(request: HTTPConnection, exc: AppException) -> JSONResponse | None:
|
||||
"""Handle application exceptions for both HTTP and WebSocket scopes.
|
||||
|
||||
Logs 5xx errors as errors and 4xx as warnings. Returns a JSON response
|
||||
for HTTP scopes; returns ``None`` for WebSocket scopes (Starlette will
|
||||
close the socket on its own).
|
||||
"""
|
||||
log_extra = {
|
||||
"error_code": exc.code,
|
||||
"status_code": exc.status_code,
|
||||
"details": exc.details,
|
||||
**_connection_meta(request),
|
||||
}
|
||||
|
||||
if exc.status_code >= 500:
|
||||
logger.error(f"{exc.code}: {exc.message}", extra=log_extra)
|
||||
else:
|
||||
logger.warning(f"{exc.code}: {exc.message}", extra=log_extra)
|
||||
|
||||
if _is_websocket(request):
|
||||
return None
|
||||
|
||||
headers: dict[str, str] = {}
|
||||
if exc.status_code == 401:
|
||||
headers["WWW-Authenticate"] = "Bearer"
|
||||
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={
|
||||
"error": {
|
||||
"code": exc.code,
|
||||
"message": exc.message,
|
||||
"details": exc.details or None,
|
||||
}
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
async def unhandled_exception_handler(
|
||||
request: HTTPConnection, exc: Exception
|
||||
) -> JSONResponse | None:
|
||||
"""Handle unexpected exceptions.
|
||||
|
||||
Logs the full exception but returns a generic error to the client
|
||||
to avoid leaking sensitive information.
|
||||
"""
|
||||
logger.exception("Unhandled exception", extra=_connection_meta(request))
|
||||
|
||||
if _is_websocket(request):
|
||||
return None
|
||||
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": {
|
||||
"code": "INTERNAL_ERROR",
|
||||
"message": "An unexpected error occurred",
|
||||
"details": None,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI) -> None:
|
||||
"""Register all exception handlers on the FastAPI app.
|
||||
|
||||
Call this after creating the FastAPI application instance.
|
||||
"""
|
||||
app.add_exception_handler(AppException, app_exception_handler)
|
||||
# Uncomment to catch all unhandled exceptions:
|
||||
# app.add_exception_handler(Exception, unhandled_exception_handler)
|
||||
@@ -0,0 +1,10 @@
|
||||
"""API router aggregation."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.routes.v1 import v1_router
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
# API v1 routes (prefix is set in main.py via settings.API_V1_STR)
|
||||
api_router.include_router(v1_router)
|
||||
@@ -0,0 +1,9 @@
|
||||
"""API routes.
|
||||
|
||||
This package contains versioned API routes.
|
||||
Add new versions by creating new folders (e.g., v2/) and updating router.py.
|
||||
"""
|
||||
|
||||
from app.api.routes.v1 import v1_router
|
||||
|
||||
__all__ = ["v1_router"]
|
||||
@@ -0,0 +1,61 @@
|
||||
"""API v1 router aggregation."""
|
||||
# ruff: noqa: I001 - Imports structured for Jinja2 template conditionals
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.routes.v1 import health
|
||||
from app.api.routes.v1 import admin_users, auth, users
|
||||
from app.api.routes.v1 import admin_ratings
|
||||
from app.api.routes.v1 import conversations
|
||||
from app.api.routes.v1 import admin_conversations
|
||||
from app.api.routes.v1 import projects
|
||||
from app.api.routes.v1 import webhooks
|
||||
from app.api.routes.v1 import agent
|
||||
from app.api.routes.v1 import rag
|
||||
from app.api.routes.v1 import files
|
||||
from app.api.routes.v1 import me_slash_commands
|
||||
from app.api.routes.v1 import admin_stats
|
||||
|
||||
v1_router = APIRouter()
|
||||
|
||||
# Health check routes (no auth required)
|
||||
v1_router.include_router(health.router, tags=["health"])
|
||||
|
||||
# Authentication routes
|
||||
v1_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
|
||||
# User routes
|
||||
v1_router.include_router(users.router, prefix="/users", tags=["users"])
|
||||
|
||||
# Admin: message-rating analytics
|
||||
v1_router.include_router(admin_ratings.router, prefix="/admin/ratings", tags=["admin:ratings"])
|
||||
|
||||
# Conversation routes (AI chat persistence)
|
||||
v1_router.include_router(conversations.router, prefix="/conversations", tags=["conversations"])
|
||||
|
||||
# Project management routes (DeepAgents)
|
||||
v1_router.include_router(projects.router, prefix="/projects", tags=["projects"])
|
||||
|
||||
# Webhook routes
|
||||
v1_router.include_router(webhooks.router, prefix="/webhooks", tags=["webhooks"])
|
||||
|
||||
# AI Agent routes
|
||||
v1_router.include_router(agent.router, tags=["agent"])
|
||||
|
||||
# RAG routes
|
||||
v1_router.include_router(rag.router, prefix="/rag", tags=["rag"])
|
||||
|
||||
# File upload/download routes
|
||||
v1_router.include_router(files.router, tags=["files"])
|
||||
|
||||
# Admin: conversation browser
|
||||
v1_router.include_router(
|
||||
admin_conversations.router, prefix="/admin/conversations", tags=["admin-conversations"]
|
||||
)
|
||||
|
||||
# Admin: user management + impersonation
|
||||
v1_router.include_router(admin_users.router, prefix="/admin/users", tags=["admin:users"])
|
||||
v1_router.include_router(
|
||||
me_slash_commands.router, prefix="/me/slash-commands", tags=["me:slash-commands"]
|
||||
)
|
||||
v1_router.include_router(admin_stats.router, prefix="/admin", tags=["admin:stats"])
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Admin conversation and user browsing routes.
|
||||
|
||||
All endpoints require admin role.
|
||||
|
||||
Endpoints:
|
||||
GET /admin/conversations — List all conversations (paginated, filterable)
|
||||
GET /admin/conversations/{id} — Get any conversation with messages (read-only)
|
||||
GET /admin/users — List all users with conversation counts
|
||||
GET /admin/users/{user_id}/conversations — List conversations for a specific user
|
||||
"""
|
||||
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.api.deps import ConversationSvc, CurrentAdmin, UserSvc
|
||||
from app.schemas.conversation import ConversationReadWithMessages
|
||||
from app.schemas.conversation_share import AdminConversationList, AdminUserList
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=AdminConversationList)
|
||||
async def admin_list_conversations(
|
||||
service: ConversationSvc,
|
||||
_: CurrentAdmin,
|
||||
skip: int = Query(0, ge=0, description="Items to skip"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Max items to return"),
|
||||
search: str | None = Query(default=None, description="Search by title"),
|
||||
user_id: UUID | None = Query(default=None, description="Filter by user ID"),
|
||||
status: Literal["active", "archived", "all"] = Query(
|
||||
"active", description="Filter by archival status"
|
||||
),
|
||||
sort_by: Literal["title", "owner", "messages", "created_at", "updated_at"] = Query(
|
||||
"updated_at", description="Sort column"
|
||||
),
|
||||
sort_dir: Literal["asc", "desc"] = Query("desc", description="Sort direction"),
|
||||
) -> Any:
|
||||
"""List all conversations across all users (admin only)."""
|
||||
return await service.admin_list_with_users(
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
search=search,
|
||||
user_id=user_id,
|
||||
include_archived=status == "all",
|
||||
archived_only=status == "archived",
|
||||
sort_by=sort_by,
|
||||
sort_dir=sort_dir,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/users", response_model=AdminUserList)
|
||||
async def admin_list_users(
|
||||
user_service: UserSvc,
|
||||
_: CurrentAdmin,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
search: str | None = Query(default=None, description="Search by email or name"),
|
||||
sort_by: Literal["email", "full_name", "conversations", "created_at"] = Query(
|
||||
"created_at", description="Sort column"
|
||||
),
|
||||
sort_dir: Literal["asc", "desc"] = Query("desc", description="Sort direction"),
|
||||
) -> Any:
|
||||
"""List all users with conversation counts (admin only)."""
|
||||
return await user_service.admin_list_with_counts(
|
||||
skip=skip, limit=limit, search=search, sort_by=sort_by, sort_dir=sort_dir
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{conversation_id}", response_model=ConversationReadWithMessages)
|
||||
async def admin_get_conversation(
|
||||
conversation_id: UUID,
|
||||
service: ConversationSvc,
|
||||
_: CurrentAdmin,
|
||||
) -> Any:
|
||||
"""Get any conversation with messages (admin read-only access)."""
|
||||
return await service.get_conversation_with_messages(conversation_id)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Admin endpoints for message ratings.
|
||||
|
||||
Provides endpoints for administrators to view and analyze user ratings
|
||||
on AI assistant messages.
|
||||
|
||||
The endpoints are:
|
||||
- GET /admin/ratings - List all ratings with filtering
|
||||
- GET /admin/ratings/summary - Get aggregated rating statistics
|
||||
- GET /admin/ratings/export - Export ratings as JSON or CSV
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from app.api.deps import CurrentAdmin, MessageRatingSvc
|
||||
from app.schemas.message_rating import MessageRatingList, RatingSummary
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=MessageRatingList)
|
||||
async def list_ratings_admin(
|
||||
rating_service: MessageRatingSvc,
|
||||
_: CurrentAdmin,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
rating_filter: int | None = Query(None, ge=-1, le=1, description="Filter by rating value"),
|
||||
with_comments_only: bool = Query(False, description="Only show ratings with comments"),
|
||||
) -> Any:
|
||||
"""List all ratings with filtering (admin only).
|
||||
|
||||
Returns paginated list of ratings with optional filters:
|
||||
- rating_filter: Filter by rating value (1 for likes, -1 for dislikes)
|
||||
- with_comments_only: Only return ratings that have comments
|
||||
|
||||
Results are ordered by creation date (newest first).
|
||||
"""
|
||||
items, total = await rating_service.list_ratings(
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
rating_filter=rating_filter,
|
||||
with_comments_only=with_comments_only,
|
||||
)
|
||||
return MessageRatingList(items=items, total=total)
|
||||
|
||||
|
||||
@router.get("/summary", response_model=RatingSummary)
|
||||
async def get_rating_summary(
|
||||
rating_service: MessageRatingSvc,
|
||||
_: CurrentAdmin,
|
||||
days: int = Query(30, ge=1, le=365, description="Number of days to include"),
|
||||
) -> Any:
|
||||
"""Get aggregated rating statistics (admin only).
|
||||
|
||||
Returns summary statistics including:
|
||||
- Total ratings count
|
||||
- Like/dislike counts
|
||||
- Average rating (-1.0 to 1.0)
|
||||
- Count of ratings with comments
|
||||
- Daily breakdown of ratings
|
||||
|
||||
The `days` parameter controls the time window (default: 30 days).
|
||||
"""
|
||||
return await rating_service.get_summary(days=days)
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
async def export_ratings(
|
||||
rating_service: MessageRatingSvc,
|
||||
_: CurrentAdmin,
|
||||
export_format: str = Query("json", description="Export format: 'json' or 'csv'"),
|
||||
rating_filter: int | None = Query(None, ge=-1, le=1, description="Filter by rating value"),
|
||||
with_comments_only: bool = Query(False, description="Only show ratings with comments"),
|
||||
) -> Any:
|
||||
"""Export all ratings as JSON or CSV (admin only).
|
||||
|
||||
CSV is streamed row-by-row; JSON collects into a single document.
|
||||
"""
|
||||
result = await rating_service.export_ratings(
|
||||
export_format=export_format,
|
||||
rating_filter=rating_filter,
|
||||
with_comments_only=with_comments_only,
|
||||
)
|
||||
if result.media_type == "text/csv":
|
||||
return StreamingResponse(
|
||||
result.payload,
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": result.content_disposition},
|
||||
)
|
||||
return JSONResponse(
|
||||
content=result.payload,
|
||||
headers={"Content-Disposition": result.content_disposition},
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Admin observability — workspace stats + Stripe event log."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.api.deps import AdminSvc, CurrentAdmin
|
||||
from app.schemas.admin import AdminStats, StripeEventList, StripeEventRead
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/stats", response_model=AdminStats)
|
||||
async def get_admin_stats(
|
||||
service: AdminSvc,
|
||||
_user: CurrentAdmin,
|
||||
) -> Any:
|
||||
"""Aggregate workspace metrics. Billing fields are 0 when billing is
|
||||
disabled in the deployment."""
|
||||
return await service.workspace_stats()
|
||||
|
||||
|
||||
@router.get("/stripe-events", response_model=StripeEventList)
|
||||
async def list_stripe_events(
|
||||
service: AdminSvc,
|
||||
_user: CurrentAdmin,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
) -> Any:
|
||||
"""List recent Stripe webhook events from the idempotency log.
|
||||
|
||||
Best-effort: returns empty list when the StripeEvent table doesn't exist
|
||||
(billing disabled in this deployment).
|
||||
"""
|
||||
rows, total = await service.list_stripe_events(skip=skip, limit=limit)
|
||||
items = [StripeEventRead.model_validate(row) for row in rows]
|
||||
return StripeEventList(items=items, total=total)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Admin user management routes.
|
||||
|
||||
All endpoints require the admin role (CurrentAdmin).
|
||||
|
||||
Endpoints:
|
||||
GET /admin/users — List all users (paginated + search)
|
||||
GET /admin/users/{user_id} — Get a single user
|
||||
PATCH /admin/users/{user_id} — Update user (role, is_active, is_app_admin)
|
||||
DELETE /admin/users/{user_id} — Hard-delete a user
|
||||
POST /admin/users/{user_id}/impersonate — Issue short-lived token to act as user
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Query, Request, status
|
||||
|
||||
from app.api.deps import CurrentAdmin, DBSession, UserSvc
|
||||
from app.core.security import create_access_token
|
||||
from app.schemas.conversation_share import AdminUserList
|
||||
from app.schemas.user import UserRead, UserUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=AdminUserList)
|
||||
async def list_users(
|
||||
_: CurrentAdmin,
|
||||
service: UserSvc,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
search: str | None = Query(None),
|
||||
) -> Any:
|
||||
result = await service.admin_list_with_counts(skip=skip, limit=limit, search=search)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserRead)
|
||||
async def get_user(
|
||||
user_id: UUID,
|
||||
_: CurrentAdmin,
|
||||
service: UserSvc,
|
||||
) -> Any:
|
||||
return await service.get_by_id(user_id)
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserRead)
|
||||
async def update_user(
|
||||
request: Request,
|
||||
user_id: UUID,
|
||||
user_in: UserUpdate,
|
||||
admin: CurrentAdmin,
|
||||
db: DBSession,
|
||||
service: UserSvc,
|
||||
) -> Any:
|
||||
user = await service.update(user_id, user_in)
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
||||
async def delete_user(
|
||||
request: Request,
|
||||
user_id: UUID,
|
||||
admin: CurrentAdmin,
|
||||
db: DBSession,
|
||||
service: UserSvc,
|
||||
) -> None:
|
||||
await service.get_by_id(user_id) # raises 404 if not found
|
||||
await service.delete(user_id)
|
||||
|
||||
|
||||
@router.post("/{user_id}/impersonate", response_model=dict)
|
||||
async def impersonate_user(
|
||||
request: Request,
|
||||
user_id: UUID,
|
||||
admin: CurrentAdmin,
|
||||
db: DBSession,
|
||||
service: UserSvc,
|
||||
) -> Any:
|
||||
"""Issue a short-lived (1h) access token to act as the target user."""
|
||||
target = await service.get_by_id(user_id)
|
||||
token = create_access_token(
|
||||
subject=str(target.id),
|
||||
expires_delta=timedelta(hours=1),
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"impersonated_user_id": str(target.id),
|
||||
"impersonated_by": str(admin.id),
|
||||
"expires_in": 3600,
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
"""AI Agent WebSocket route.
|
||||
|
||||
The route is just lifecycle plumbing — auth, accept, dispatch loop, disconnect.
|
||||
Per-turn orchestration lives in :class:`app.services.agent_session.AgentSession`.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
|
||||
from pydantic_ai import (
|
||||
FinalResultEvent,
|
||||
FunctionToolCallEvent,
|
||||
FunctionToolResultEvent,
|
||||
PartDeltaEvent,
|
||||
PartStartEvent,
|
||||
TextPartDelta,
|
||||
)
|
||||
from pydantic_ai.messages import TextPart
|
||||
from pydantic_ai_backends import StateBackend
|
||||
|
||||
from app.agents.pydantic_deep_assistant import PydanticDeepContext, get_agent
|
||||
from app.api.deps import get_conversation_service, get_current_user_ws, get_project_service
|
||||
from app.core.config import settings
|
||||
from app.db.models.user import User
|
||||
from app.db.session import get_db_context
|
||||
from app.schemas.conversation import ConversationCreate, MessageCreate
|
||||
from app.services.agent import AgentConnectionManager, send_event
|
||||
from app.services.agent_session import AgentSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
manager = AgentConnectionManager()
|
||||
|
||||
|
||||
@router.get("/agent/models")
|
||||
async def list_models() -> dict[str, Any]:
|
||||
"""Return available LLM models and the current default."""
|
||||
return {
|
||||
"default": settings.AI_MODEL,
|
||||
"models": settings.AI_AVAILABLE_MODELS,
|
||||
}
|
||||
|
||||
|
||||
@router.websocket("/ws/agent")
|
||||
async def agent_websocket(
|
||||
websocket: WebSocket,
|
||||
user: User = Depends(get_current_user_ws),
|
||||
) -> None:
|
||||
"""WebSocket endpoint for the AI agent.
|
||||
|
||||
Streams agent events to the client. Each incoming JSON message is forwarded to
|
||||
:class:`AgentSession.process_message`.
|
||||
|
||||
Expected input format::
|
||||
|
||||
{
|
||||
"message": "user message here",
|
||||
"file_ids": ["..."],
|
||||
"conversation_id": "optional-uuid",
|
||||
"model": "optional-model-override",
|
||||
"thinking_effort": "optional"
|
||||
}
|
||||
|
||||
Authentication: handled by ``get_current_user_ws`` (JWT).
|
||||
"""
|
||||
if user is None:
|
||||
return
|
||||
|
||||
await manager.connect(websocket)
|
||||
session = AgentSession(
|
||||
websocket,
|
||||
user,
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
data = await websocket.receive_json()
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
|
||||
try:
|
||||
await session.process_message(data)
|
||||
except WebSocketDisconnect:
|
||||
logger.info("Client disconnected during agent processing")
|
||||
break
|
||||
finally:
|
||||
manager.disconnect(websocket)
|
||||
|
||||
|
||||
@router.websocket("/ws/projects/{project_id}/chats/{conversation_id}")
|
||||
async def project_chat_websocket(
|
||||
project_id: UUID,
|
||||
conversation_id: UUID,
|
||||
websocket: WebSocket,
|
||||
user: User = Depends(get_current_user_ws),
|
||||
) -> None:
|
||||
"""WebSocket endpoint for project-scoped PydanticDeep chat.
|
||||
|
||||
One Docker container per project is shared across all chats.
|
||||
Chat history is stored per-chat inside the project volume at:
|
||||
.pydantic-deep/sessions/{conversation_id}/messages.json
|
||||
"""
|
||||
|
||||
await manager.connect(websocket)
|
||||
|
||||
context: PydanticDeepContext = {}
|
||||
context["user_id"] = str(user.id) if user else None
|
||||
context["user_name"] = user.email if user else None
|
||||
|
||||
try:
|
||||
# Verify project access
|
||||
async with get_db_context() as db:
|
||||
project_service = get_project_service(db)
|
||||
try:
|
||||
await project_service.get(project_id, user_id=user.id)
|
||||
except Exception as exc:
|
||||
await websocket.close(code=4003, reason=str(exc))
|
||||
return
|
||||
|
||||
backend: Any = StateBackend()
|
||||
|
||||
assistant = get_agent(
|
||||
conversation_id=str(conversation_id),
|
||||
backend_override=backend,
|
||||
history_messages_path=f".pydantic-deep/sessions/{conversation_id}/messages.json",
|
||||
)
|
||||
|
||||
# Ensure the conversation record exists and is linked to the project
|
||||
async with get_db_context() as db:
|
||||
conv_service = get_conversation_service(db)
|
||||
try:
|
||||
await conv_service.get_conversation(conversation_id, user_id=user.id)
|
||||
except Exception:
|
||||
conv = await conv_service.create_conversation(
|
||||
ConversationCreate(
|
||||
user_id=user.id,
|
||||
project_id=project_id,
|
||||
)
|
||||
)
|
||||
await send_event(
|
||||
websocket,
|
||||
"conversation_created",
|
||||
{"conversation_id": str(conv.id), "project_id": str(project_id)},
|
||||
)
|
||||
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
user_message = data.get("message", "")
|
||||
|
||||
if not user_message:
|
||||
await send_event(websocket, "error", {"message": "Empty message"})
|
||||
continue
|
||||
|
||||
await send_event(websocket, "user_prompt", {"content": user_message})
|
||||
|
||||
async with get_db_context() as db:
|
||||
conv_service = get_conversation_service(db)
|
||||
try:
|
||||
await conv_service.add_message(
|
||||
conversation_id,
|
||||
MessageCreate(role="user", content=user_message),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to persist user message: %s", exc)
|
||||
|
||||
try:
|
||||
await send_event(websocket, "model_request_start", {})
|
||||
|
||||
async with assistant.agent.run_stream(user_message, deps=assistant.deps) as stream:
|
||||
async for event in stream.stream_events():
|
||||
if isinstance(event, PartDeltaEvent) and isinstance(
|
||||
event.delta, TextPartDelta
|
||||
):
|
||||
await send_event(
|
||||
websocket, "text_delta", {"delta": event.delta.content_delta}
|
||||
)
|
||||
elif isinstance(event, PartStartEvent) and isinstance(event.part, TextPart):
|
||||
pass
|
||||
elif isinstance(event, FunctionToolCallEvent):
|
||||
await send_event(
|
||||
websocket,
|
||||
"tool_call",
|
||||
{
|
||||
"tool_name": event.part.tool_name,
|
||||
"args": str(event.part.args),
|
||||
},
|
||||
)
|
||||
elif isinstance(event, FunctionToolResultEvent):
|
||||
await send_event(
|
||||
websocket,
|
||||
"tool_result",
|
||||
{
|
||||
"tool_name": event.result.tool_name,
|
||||
"content": str(event.result.content),
|
||||
},
|
||||
)
|
||||
elif isinstance(event, FinalResultEvent):
|
||||
await send_event(
|
||||
websocket, "final_result", {"content": str(event.output)}
|
||||
)
|
||||
|
||||
result = stream.result()
|
||||
|
||||
async with get_db_context() as db:
|
||||
conv_service = get_conversation_service(db)
|
||||
try:
|
||||
await conv_service.add_message(
|
||||
conversation_id,
|
||||
MessageCreate(
|
||||
role="assistant",
|
||||
content=getattr(result, "output", ""),
|
||||
model_name=getattr(assistant, "model_name", None),
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to persist assistant response: %s", exc)
|
||||
|
||||
await send_event(
|
||||
websocket,
|
||||
"complete",
|
||||
{
|
||||
"conversation_id": str(conversation_id),
|
||||
"project_id": str(project_id),
|
||||
},
|
||||
)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.info("Client disconnected during project chat")
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.exception("Error in project chat: %s", exc)
|
||||
await send_event(websocket, "error", {"message": str(exc)})
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
manager.disconnect(websocket)
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Authentication routes."""
|
||||
|
||||
import logging
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
from app.api.deps import CurrentUser, UserSvc
|
||||
from app.core.exceptions import AuthenticationError
|
||||
from app.core.security import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
verify_token,
|
||||
)
|
||||
from app.schemas.token import RefreshTokenRequest, Token
|
||||
from app.schemas.user import UserCreate, UserRead
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(
|
||||
request: Request,
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||
user_service: UserSvc,
|
||||
) -> Any:
|
||||
"""OAuth2 compatible token login.
|
||||
|
||||
Returns access token and refresh token.
|
||||
Raises domain exceptions handled by exception handlers.
|
||||
"""
|
||||
user = await user_service.authenticate(form_data.username, form_data.password)
|
||||
access_token = create_access_token(subject=str(user.id))
|
||||
refresh_token = create_refresh_token(subject=str(user.id))
|
||||
return Token(access_token=access_token, refresh_token=refresh_token)
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserRead, status_code=status.HTTP_201_CREATED)
|
||||
async def register(
|
||||
user_in: UserCreate,
|
||||
user_service: UserSvc,
|
||||
) -> Any:
|
||||
"""Register a new user.
|
||||
|
||||
Raises AlreadyExistsError if email is already registered.
|
||||
"""
|
||||
user = await user_service.register(user_in)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=Token)
|
||||
async def refresh_token(
|
||||
request: Request,
|
||||
body: RefreshTokenRequest,
|
||||
user_service: UserSvc,
|
||||
) -> Any:
|
||||
"""Get new access token using refresh token.
|
||||
|
||||
Raises AuthenticationError if refresh token is invalid or expired.
|
||||
"""
|
||||
|
||||
# No DB-backed sessions — validate the refresh JWT directly.
|
||||
payload = verify_token(body.refresh_token)
|
||||
if not payload or payload.get("type") != "refresh":
|
||||
raise AuthenticationError(message="Invalid or expired refresh token")
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
raise AuthenticationError(message="Invalid refresh token")
|
||||
user = await user_service.get_by_id(user_id)
|
||||
if not user.is_active:
|
||||
raise AuthenticationError(message="User account is disabled")
|
||||
|
||||
access_token = create_access_token(subject=str(user.id))
|
||||
new_refresh_token = create_refresh_token(subject=str(user.id))
|
||||
return Token(access_token=access_token, refresh_token=new_refresh_token)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
||||
async def logout(
|
||||
body: RefreshTokenRequest,
|
||||
) -> None:
|
||||
"""No-op without session tracking. Clients drop their JWTs locally."""
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserRead)
|
||||
async def get_current_user_info(current_user: CurrentUser) -> Any:
|
||||
"""Get current authenticated user information."""
|
||||
return current_user
|
||||
@@ -0,0 +1,363 @@
|
||||
"""Conversation API routes for AI chat persistence.
|
||||
|
||||
Provides CRUD operations for conversations and messages.
|
||||
|
||||
The endpoints are:
|
||||
- GET /conversations - List user's conversations
|
||||
- POST /conversations - Create a new conversation
|
||||
- GET /conversations/{id} - Get a conversation with messages
|
||||
- PATCH /conversations/{id} - Update conversation title/archived status
|
||||
- DELETE /conversations/{id} - Delete a conversation
|
||||
- POST /conversations/{id}/messages - Add a message to conversation
|
||||
- GET /conversations/{id}/messages - List messages in conversation
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Query, Response, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.api.deps import (
|
||||
ConversationShareSvc,
|
||||
ConversationSvc,
|
||||
CurrentAdmin,
|
||||
CurrentUser,
|
||||
MessageRatingSvc,
|
||||
)
|
||||
from app.schemas.conversation import (
|
||||
ConversationAdminList,
|
||||
ConversationCreate,
|
||||
ConversationList,
|
||||
ConversationRead,
|
||||
ConversationReadWithMessages,
|
||||
ConversationUpdate,
|
||||
MessageCreate,
|
||||
MessageList,
|
||||
MessageRead,
|
||||
)
|
||||
from app.schemas.conversation_share import (
|
||||
ConversationShareCreate,
|
||||
ConversationShareList,
|
||||
ConversationShareRead,
|
||||
)
|
||||
from app.schemas.message_rating import (
|
||||
MessageRatingCreate,
|
||||
MessageRatingRead,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
async def export_conversations(
|
||||
conversation_service: ConversationSvc,
|
||||
_: CurrentAdmin,
|
||||
) -> Any:
|
||||
"""Export all conversations with messages and tool calls (admin only)."""
|
||||
export_data = await conversation_service.export_all()
|
||||
return JSONResponse(
|
||||
content={"conversations": export_data, "total": len(export_data)},
|
||||
headers={"Content-Disposition": 'attachment; filename="conversations_export.json"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin-list", response_model=ConversationAdminList)
|
||||
async def list_conversations_admin(
|
||||
conversation_service: ConversationSvc,
|
||||
_: CurrentAdmin,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
include_archived: bool = Query(True, description="Include archived conversations"),
|
||||
search: str | None = Query(None, max_length=100, description="Search by title or ID prefix"),
|
||||
) -> Any:
|
||||
"""List all conversations with message counts (admin only).
|
||||
|
||||
Returns paginated conversations without message content.
|
||||
"""
|
||||
items, total = await conversation_service.list_conversations_admin(
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
include_archived=include_archived,
|
||||
search=search,
|
||||
)
|
||||
return ConversationAdminList(items=items, total=total)
|
||||
|
||||
|
||||
@router.get("", response_model=ConversationList)
|
||||
async def list_conversations(
|
||||
conversation_service: ConversationSvc,
|
||||
current_user: CurrentUser,
|
||||
skip: int = Query(0, ge=0, description="Number of conversations to skip"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Maximum conversations to return"),
|
||||
include_archived: bool = Query(False, description="Include archived conversations"),
|
||||
) -> Any:
|
||||
"""List conversations for the current user.
|
||||
|
||||
Returns conversations ordered by most recently updated.
|
||||
"""
|
||||
items, total = await conversation_service.list_conversations(
|
||||
user_id=current_user.id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
include_archived=include_archived,
|
||||
)
|
||||
return ConversationList(items=items, total=total) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@router.post("", response_model=ConversationRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_conversation(
|
||||
conversation_service: ConversationSvc,
|
||||
current_user: CurrentUser,
|
||||
data: ConversationCreate | None = None,
|
||||
) -> Any:
|
||||
"""Create a new conversation.
|
||||
|
||||
The title is optional and can be set later.
|
||||
"""
|
||||
if data is None:
|
||||
data = ConversationCreate()
|
||||
data = data.model_copy(update={"user_id": current_user.id})
|
||||
return await conversation_service.create_conversation(data)
|
||||
|
||||
|
||||
@router.get("/{conversation_id}", response_model=ConversationReadWithMessages)
|
||||
async def get_conversation(
|
||||
conversation_id: UUID,
|
||||
conversation_service: ConversationSvc,
|
||||
current_user: CurrentUser,
|
||||
) -> Any:
|
||||
"""Get a conversation with all its messages.
|
||||
|
||||
Raises 404 if the conversation does not exist.
|
||||
"""
|
||||
uid = None if current_user.role == "admin" else current_user.id
|
||||
return await conversation_service.get_conversation(
|
||||
conversation_id,
|
||||
include_messages=True,
|
||||
user_id=uid,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{conversation_id}", response_model=ConversationRead)
|
||||
async def update_conversation(
|
||||
conversation_id: UUID,
|
||||
data: ConversationUpdate,
|
||||
conversation_service: ConversationSvc,
|
||||
current_user: CurrentUser,
|
||||
) -> Any:
|
||||
"""Update a conversation's title or archived status.
|
||||
|
||||
Raises 404 if the conversation does not exist.
|
||||
"""
|
||||
return await conversation_service.update_conversation(
|
||||
conversation_id,
|
||||
data,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{conversation_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
||||
async def delete_conversation(
|
||||
conversation_id: UUID,
|
||||
conversation_service: ConversationSvc,
|
||||
current_user: CurrentUser,
|
||||
) -> None:
|
||||
"""Delete a conversation and all its messages.
|
||||
|
||||
Raises 404 if the conversation does not exist.
|
||||
"""
|
||||
await conversation_service.delete_conversation(
|
||||
conversation_id,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{conversation_id}/archive",
|
||||
response_model=ConversationRead,
|
||||
)
|
||||
async def archive_conversation(
|
||||
conversation_id: UUID,
|
||||
conversation_service: ConversationSvc,
|
||||
current_user: CurrentUser,
|
||||
) -> Any:
|
||||
"""Archive a conversation.
|
||||
|
||||
Archived conversations are hidden from the default list view.
|
||||
"""
|
||||
return await conversation_service.archive_conversation(
|
||||
conversation_id,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{conversation_id}/messages", response_model=MessageList)
|
||||
async def list_messages(
|
||||
conversation_id: UUID,
|
||||
conversation_service: ConversationSvc,
|
||||
current_user: CurrentUser,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
) -> Any:
|
||||
"""List messages in a conversation.
|
||||
|
||||
Returns messages ordered by creation time (oldest first).
|
||||
"""
|
||||
uid = None if current_user.role == "admin" else current_user.id
|
||||
items, total = await conversation_service.list_messages(
|
||||
conversation_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
include_tool_calls=True,
|
||||
user_id=uid,
|
||||
)
|
||||
return MessageList(items=items, total=total) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{conversation_id}/messages",
|
||||
response_model=MessageRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def add_message(
|
||||
conversation_id: UUID,
|
||||
data: MessageCreate,
|
||||
conversation_service: ConversationSvc,
|
||||
current_user: CurrentUser,
|
||||
) -> Any:
|
||||
"""Add a message to a conversation.
|
||||
|
||||
Raises 404 if the conversation does not exist.
|
||||
"""
|
||||
return await conversation_service.add_message(conversation_id, data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{conversation_id}/messages/{message_id}/rate",
|
||||
response_model=MessageRatingRead,
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def rate_message(
|
||||
conversation_id: UUID,
|
||||
message_id: UUID,
|
||||
data: MessageRatingCreate,
|
||||
rating_service: MessageRatingSvc,
|
||||
current_user: CurrentUser,
|
||||
response: Response,
|
||||
) -> Any:
|
||||
"""Rate an assistant message.
|
||||
|
||||
Creates a new rating or updates an existing one.
|
||||
Only assistant messages can be rated.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation containing the message
|
||||
message_id: The message to rate
|
||||
data: Rating value (1 for like, -1 for dislike) and optional comment
|
||||
|
||||
Returns:
|
||||
201 Created for new rating, 200 OK when updating existing rating.
|
||||
"""
|
||||
rating, is_new = await rating_service.rate_message(
|
||||
conversation_id=conversation_id,
|
||||
message_id=message_id,
|
||||
user_id=current_user.id,
|
||||
data=data,
|
||||
)
|
||||
if is_new:
|
||||
response.status_code = status.HTTP_201_CREATED
|
||||
return rating
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{conversation_id}/messages/{message_id}/rate",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_model=None,
|
||||
)
|
||||
async def remove_rating(
|
||||
conversation_id: UUID,
|
||||
message_id: UUID,
|
||||
rating_service: MessageRatingSvc,
|
||||
current_user: CurrentUser,
|
||||
) -> None:
|
||||
"""Remove your rating from a message.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation containing the message
|
||||
message_id: The message to remove rating from
|
||||
"""
|
||||
await rating_service.remove_rating(
|
||||
conversation_id=conversation_id,
|
||||
message_id=message_id,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/shared-with-me", response_model=ConversationList)
|
||||
async def list_shared_with_me(
|
||||
share_service: ConversationShareSvc,
|
||||
current_user: CurrentUser,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
) -> Any:
|
||||
"""List conversations shared with the current user."""
|
||||
items, total = await share_service.list_shared_with_me(current_user.id, skip=skip, limit=limit)
|
||||
return ConversationList(items=items, total=total)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{conversation_id}/shares",
|
||||
response_model=ConversationShareRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def share_conversation(
|
||||
conversation_id: UUID,
|
||||
data: ConversationShareCreate,
|
||||
share_service: ConversationShareSvc,
|
||||
current_user: CurrentUser,
|
||||
) -> Any:
|
||||
"""Share a conversation with another user or generate a public link."""
|
||||
result = await share_service.share_conversation(
|
||||
conversation_id,
|
||||
shared_by=current_user.id,
|
||||
shared_with=data.shared_with,
|
||||
generate_link=data.generate_link,
|
||||
permission=data.permission,
|
||||
)
|
||||
return result["share"]
|
||||
|
||||
|
||||
@router.get("/{conversation_id}/shares", response_model=ConversationShareList)
|
||||
async def list_shares(
|
||||
conversation_id: UUID,
|
||||
share_service: ConversationShareSvc,
|
||||
current_user: CurrentUser,
|
||||
) -> Any:
|
||||
"""List all shares for a conversation (owner only)."""
|
||||
shares = await share_service.list_shares(conversation_id, current_user.id)
|
||||
return ConversationShareList(items=shares, total=len(shares))
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{conversation_id}/shares/{share_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_model=None,
|
||||
)
|
||||
async def revoke_share(
|
||||
conversation_id: UUID,
|
||||
share_id: UUID,
|
||||
share_service: ConversationShareSvc,
|
||||
current_user: CurrentUser,
|
||||
) -> None:
|
||||
"""Revoke a conversation share."""
|
||||
await share_service.revoke_share(share_id, current_user.id)
|
||||
|
||||
|
||||
@router.get("/shared/{token}")
|
||||
async def get_shared_conversation(
|
||||
token: str,
|
||||
share_service: ConversationShareSvc,
|
||||
) -> Any:
|
||||
"""Access a shared conversation via public token (no auth required)."""
|
||||
return await share_service.get_by_token(token)
|
||||
@@ -0,0 +1,121 @@
|
||||
"""File upload and download endpoints for chat attachments."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.api.deps import CurrentUser, FileUploadSvc
|
||||
from app.core.exceptions import NotFoundError
|
||||
from app.schemas.file import FileInfo, FileUploadResponse
|
||||
from app.services.file_storage import get_file_storage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/files", tags=["files"])
|
||||
|
||||
|
||||
@router.post("/upload", response_model=FileUploadResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def upload_file(
|
||||
file_upload_svc: FileUploadSvc,
|
||||
current_user: CurrentUser,
|
||||
file: UploadFile = File(...),
|
||||
) -> Any:
|
||||
"""Upload a file for use in chat."""
|
||||
data = await file.read()
|
||||
is_valid, error = file_upload_svc.validate_upload(file.content_type, len(data))
|
||||
if not is_valid:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
|
||||
|
||||
file_type = file_upload_svc.classify_file(file.content_type or "", file.filename or "unknown")
|
||||
parsed_content = await file_upload_svc.parse_content(data, file_type, file.content_type or "")
|
||||
|
||||
storage = get_file_storage()
|
||||
storage_path = await storage.save(str(current_user.id), file.filename or "unknown", data)
|
||||
chat_file = await file_upload_svc.create_chat_file(
|
||||
user_id=current_user.id,
|
||||
filename=file.filename or "unknown",
|
||||
mime_type=file.content_type or "application/octet-stream",
|
||||
size=len(data),
|
||||
storage_path=storage_path,
|
||||
file_type=file_type,
|
||||
parsed_content=parsed_content,
|
||||
)
|
||||
|
||||
return FileUploadResponse(
|
||||
id=chat_file.id,
|
||||
filename=chat_file.filename,
|
||||
mime_type=chat_file.mime_type,
|
||||
size=chat_file.size,
|
||||
file_type=chat_file.file_type,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{file_id}")
|
||||
async def download_file(
|
||||
file_id: UUID,
|
||||
file_upload_svc: FileUploadSvc,
|
||||
current_user: CurrentUser,
|
||||
disposition: str = "inline",
|
||||
) -> Any:
|
||||
"""Serve a file. Only the owner can access their files.
|
||||
|
||||
By default the response is ``Content-Disposition: inline`` so PDFs, images
|
||||
and audio/video render directly inside an ``<iframe>`` / media tag (used
|
||||
by the chat file-preview panel). Pass ``?disposition=attachment`` to force
|
||||
the browser's download dialog (used by the explicit "Download" button).
|
||||
"""
|
||||
try:
|
||||
chat_file = await file_upload_svc.get_user_file(file_id, current_user.id)
|
||||
except NotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="File not found"
|
||||
) from None
|
||||
|
||||
storage = get_file_storage()
|
||||
file_path = storage.get_full_path(chat_file.storage_path)
|
||||
if not file_path:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found on disk")
|
||||
|
||||
# FastAPI's ``FileResponse(filename=...)`` always uses ``attachment`` —
|
||||
# build the header manually so we can switch to ``inline`` for previews.
|
||||
mode = "attachment" if disposition == "attachment" else "inline"
|
||||
safe_name = chat_file.filename.replace('"', "")
|
||||
# The chat file-preview panel embeds this URL in an iframe (PDFs, HTML,
|
||||
# etc). Default ``X-Frame-Options: DENY`` from SecurityHeadersMiddleware
|
||||
# would break that, so opt this endpoint down to SAMEORIGIN. The CSP
|
||||
# ``frame-ancestors 'self'`` is the modern equivalent — browsers honor
|
||||
# whichever they recognize.
|
||||
headers = {
|
||||
"Content-Disposition": f'{mode}; filename="{safe_name}"',
|
||||
"X-Frame-Options": "SAMEORIGIN",
|
||||
"Content-Security-Policy": "frame-ancestors 'self'",
|
||||
}
|
||||
return FileResponse(path=file_path, media_type=chat_file.mime_type, headers=headers)
|
||||
|
||||
|
||||
@router.get("/{file_id}/info", response_model=FileInfo)
|
||||
async def get_file_info(
|
||||
file_id: UUID,
|
||||
file_upload_svc: FileUploadSvc,
|
||||
current_user: CurrentUser,
|
||||
) -> Any:
|
||||
"""Get file metadata. Only the owner can access."""
|
||||
try:
|
||||
chat_file = await file_upload_svc.get_user_file(file_id, current_user.id)
|
||||
except NotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="File not found"
|
||||
) from None
|
||||
|
||||
return FileInfo(
|
||||
id=chat_file.id,
|
||||
filename=chat_file.filename,
|
||||
mime_type=chat_file.mime_type,
|
||||
size=chat_file.size,
|
||||
file_type=chat_file.file_type,
|
||||
created_at=chat_file.created_at,
|
||||
user_id=chat_file.user_id,
|
||||
)
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Health check endpoints.
|
||||
|
||||
Provides Kubernetes-compatible health check endpoints:
|
||||
- /health - Simple liveness check
|
||||
- /health/live - Detailed liveness probe
|
||||
- /health/ready - Readiness probe with dependency checks
|
||||
"""
|
||||
# ruff: noqa: I001 - Imports structured for Jinja2 template conditionals
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import text
|
||||
from app.api.deps import DBSession, Redis
|
||||
from app.core.config import settings
|
||||
from app.services.health import build_health_response
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check() -> dict[str, Any]:
|
||||
"""Simple liveness probe - check if application is running.
|
||||
|
||||
This is a lightweight check that should always succeed if the
|
||||
application is running. Use this for basic connectivity tests.
|
||||
|
||||
Returns:
|
||||
{"status": "healthy"}
|
||||
"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"max_upload_size_mb": settings.MAX_UPLOAD_SIZE_MB,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health/live")
|
||||
async def liveness_probe() -> dict[str, Any]:
|
||||
"""Detailed liveness probe for Kubernetes.
|
||||
|
||||
This endpoint is designed for Kubernetes liveness probes.
|
||||
It checks if the application process is alive and responding.
|
||||
Failure indicates the container should be restarted.
|
||||
|
||||
Returns:
|
||||
Structured response with timestamp and service info.
|
||||
"""
|
||||
return build_health_response(
|
||||
status="alive",
|
||||
details={
|
||||
"version": getattr(settings, "VERSION", "1.0.0"),
|
||||
"environment": settings.ENVIRONMENT,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/health/ready", response_model=None)
|
||||
async def readiness_probe(
|
||||
db: DBSession,
|
||||
redis: Redis,
|
||||
) -> dict[str, Any] | JSONResponse:
|
||||
"""Readiness probe for Kubernetes.
|
||||
|
||||
This endpoint checks if all dependencies are ready to handle traffic.
|
||||
It verifies database connections, Redis, and other critical services.
|
||||
Failure indicates traffic should be temporarily diverted.
|
||||
|
||||
Checks performed:
|
||||
- Database connectivity
|
||||
- Redis connectivity
|
||||
|
||||
Returns:
|
||||
Structured response with individual check results.
|
||||
Returns 503 if any critical check fails.
|
||||
"""
|
||||
checks: dict[str, dict[str, Any]] = {}
|
||||
# Database check
|
||||
try:
|
||||
start = datetime.now(UTC)
|
||||
await db.execute(text("SELECT 1"))
|
||||
latency_ms = (datetime.now(UTC) - start).total_seconds() * 1000
|
||||
checks["database"] = {
|
||||
"status": "healthy",
|
||||
"latency_ms": round(latency_ms, 2),
|
||||
"type": "postgresql",
|
||||
}
|
||||
except Exception as e:
|
||||
checks["database"] = {
|
||||
"status": "unhealthy",
|
||||
"error": str(e),
|
||||
"type": "postgresql",
|
||||
}
|
||||
# Redis check
|
||||
try:
|
||||
start = datetime.now(UTC)
|
||||
is_healthy = await redis.ping()
|
||||
latency_ms = (datetime.now(UTC) - start).total_seconds() * 1000
|
||||
if is_healthy:
|
||||
checks["redis"] = {
|
||||
"status": "healthy",
|
||||
"latency_ms": round(latency_ms, 2),
|
||||
}
|
||||
else:
|
||||
checks["redis"] = {
|
||||
"status": "unhealthy",
|
||||
"error": "Ping failed",
|
||||
}
|
||||
except Exception as e:
|
||||
checks["redis"] = {
|
||||
"status": "unhealthy",
|
||||
"error": str(e),
|
||||
}
|
||||
# Vector store — Qdrant connectivity probe (TCP).
|
||||
try:
|
||||
import socket
|
||||
|
||||
start = datetime.now(UTC)
|
||||
with socket.create_connection((settings.QDRANT_HOST, settings.QDRANT_PORT), timeout=2):
|
||||
pass
|
||||
latency_ms = (datetime.now(UTC) - start).total_seconds() * 1000
|
||||
checks["vector_store"] = {
|
||||
"status": "healthy",
|
||||
"latency_ms": round(latency_ms, 2),
|
||||
"type": "qdrant",
|
||||
}
|
||||
except Exception as e:
|
||||
checks["vector_store"] = {
|
||||
"status": "unhealthy",
|
||||
"error": str(e),
|
||||
"type": "qdrant",
|
||||
}
|
||||
|
||||
# LLM provider — config-only check (avoid spending money on a probe call).
|
||||
llm_provider = (getattr(settings, "LLM_PROVIDER", None) or "").lower()
|
||||
if llm_provider:
|
||||
key_field = {
|
||||
"openai": "OPENAI_API_KEY",
|
||||
"anthropic": "ANTHROPIC_API_KEY",
|
||||
"google": "GOOGLE_API_KEY",
|
||||
"openrouter": "OPENROUTER_API_KEY",
|
||||
}.get(llm_provider)
|
||||
api_key = getattr(settings, key_field, None) if key_field else None
|
||||
checks["llm"] = {
|
||||
"status": "healthy" if api_key else "unhealthy",
|
||||
"provider": llm_provider,
|
||||
"detail": "API key configured" if api_key else "API key missing",
|
||||
}
|
||||
else:
|
||||
checks["llm"] = {"status": "unknown", "detail": "not configured"}
|
||||
|
||||
# Determine overall health — only db + redis are critical for readiness.
|
||||
critical = {k: v for k, v in checks.items() if k in ("database", "redis")}
|
||||
all_healthy = (
|
||||
all(check.get("status") == "healthy" for check in critical.values()) if critical else True
|
||||
)
|
||||
|
||||
# The admin /system page reads each service from the top level, so flatten
|
||||
# the checks alongside the structured `checks` field for K8s probes.
|
||||
response_data = build_health_response(
|
||||
status="ready" if all_healthy else "not_ready",
|
||||
checks=checks,
|
||||
)
|
||||
response_data.update(checks)
|
||||
|
||||
if not all_healthy:
|
||||
return JSONResponse(status_code=503, content=response_data)
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
# Backward compatibility - keep /ready endpoint
|
||||
@router.get("/ready", response_model=None)
|
||||
async def readiness_check(
|
||||
db: DBSession,
|
||||
redis: Redis,
|
||||
) -> dict[str, Any] | JSONResponse:
|
||||
"""Readiness check (alias for /health/ready).
|
||||
|
||||
Deprecated: Use /health/ready instead.
|
||||
"""
|
||||
return await readiness_probe(
|
||||
db=db,
|
||||
redis=redis,
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""User-scoped slash command settings.
|
||||
|
||||
Routes are nested under ``/me/slash-commands`` because they're always
|
||||
operating on the current user — there's no cross-user view of these.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, status
|
||||
|
||||
from app.api.deps import CurrentUser, UserSlashCommandSvc
|
||||
from app.schemas.user_slash_command import (
|
||||
BuiltinOverrideUpsert,
|
||||
UserSlashCommandCustomCreate,
|
||||
UserSlashCommandList,
|
||||
UserSlashCommandRead,
|
||||
UserSlashCommandUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=UserSlashCommandList)
|
||||
async def list_slash_commands(service: UserSlashCommandSvc, user: CurrentUser) -> Any:
|
||||
"""List the current user's custom commands and built-in overrides."""
|
||||
items, total = await service.list_for_user(user_id=user.id)
|
||||
return UserSlashCommandList(
|
||||
items=[UserSlashCommandRead.model_validate(c) for c in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/custom",
|
||||
response_model=UserSlashCommandRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_custom_command(
|
||||
data: UserSlashCommandCustomCreate,
|
||||
service: UserSlashCommandSvc,
|
||||
user: CurrentUser,
|
||||
) -> Any:
|
||||
"""Create a user-defined command with a stored prompt body."""
|
||||
db_cmd = await service.create_custom(user_id=user.id, data=data)
|
||||
return UserSlashCommandRead.model_validate(db_cmd)
|
||||
|
||||
|
||||
@router.put("/builtin", response_model=UserSlashCommandRead)
|
||||
async def upsert_builtin_override(
|
||||
data: BuiltinOverrideUpsert,
|
||||
service: UserSlashCommandSvc,
|
||||
user: CurrentUser,
|
||||
) -> Any:
|
||||
"""Toggle a built-in command on or off for the current user."""
|
||||
db_cmd = await service.upsert_builtin_override(
|
||||
user_id=user.id, name=data.name, is_enabled=data.is_enabled
|
||||
)
|
||||
return UserSlashCommandRead.model_validate(db_cmd)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{command_id}",
|
||||
response_model=UserSlashCommandRead,
|
||||
)
|
||||
async def update_slash_command(
|
||||
command_id: UUID,
|
||||
data: UserSlashCommandUpdate,
|
||||
service: UserSlashCommandSvc,
|
||||
user: CurrentUser,
|
||||
) -> Any:
|
||||
"""Patch a custom command. Built-in overrides accept only ``is_enabled``."""
|
||||
db_cmd = await service.update(user_id=user.id, command_id=command_id, data=data)
|
||||
return UserSlashCommandRead.model_validate(db_cmd)
|
||||
|
||||
|
||||
@router.delete("/{command_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
||||
async def delete_slash_command(
|
||||
command_id: UUID,
|
||||
service: UserSlashCommandSvc,
|
||||
user: CurrentUser,
|
||||
) -> Any:
|
||||
"""Delete a custom command, or remove a built-in override (re-enables it)."""
|
||||
await service.delete(user_id=user.id, command_id=command_id)
|
||||
return None
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Project management routes — CRUD and member management for DeepAgents projects."""
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Query, status
|
||||
|
||||
from app.api.deps import CurrentUser, ProjectSvc
|
||||
from app.schemas.project import (
|
||||
ProjectCreate,
|
||||
ProjectList,
|
||||
ProjectMemberCreate,
|
||||
ProjectMemberList,
|
||||
ProjectMemberRead,
|
||||
ProjectMemberUpdate,
|
||||
ProjectRead,
|
||||
ProjectUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Project CRUD
|
||||
|
||||
|
||||
@router.get("", response_model=ProjectList)
|
||||
async def list_projects(
|
||||
service: ProjectSvc,
|
||||
user: CurrentUser,
|
||||
skip: int = Query(0, ge=0, description="Items to skip"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Max items to return"),
|
||||
include_archived: bool = Query(False, description="Include archived projects"),
|
||||
) -> Any:
|
||||
"""List all projects accessible to the current user (owned + member)."""
|
||||
items, total = await service.list_for_user(
|
||||
user.id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
include_archived=include_archived,
|
||||
)
|
||||
return ProjectList(items=items, total=total)
|
||||
|
||||
|
||||
@router.post("", response_model=ProjectRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_project(
|
||||
data: ProjectCreate,
|
||||
service: ProjectSvc,
|
||||
user: CurrentUser,
|
||||
) -> Any:
|
||||
"""Create a new project. A Docker volume is provisioned on creation."""
|
||||
return await service.create(data, owner_id=user.id)
|
||||
|
||||
|
||||
@router.get("/{project_id}", response_model=ProjectRead)
|
||||
async def get_project(
|
||||
project_id: UUID,
|
||||
service: ProjectSvc,
|
||||
user: CurrentUser,
|
||||
) -> Any:
|
||||
"""Get a project by ID. Requires at least viewer access."""
|
||||
return await service.get(project_id, user_id=user.id)
|
||||
|
||||
|
||||
@router.patch("/{project_id}", response_model=ProjectRead)
|
||||
async def update_project(
|
||||
project_id: UUID,
|
||||
data: ProjectUpdate,
|
||||
service: ProjectSvc,
|
||||
user: CurrentUser,
|
||||
) -> Any:
|
||||
"""Update a project. Requires admin role or ownership."""
|
||||
return await service.update(project_id, data, user_id=user.id)
|
||||
|
||||
|
||||
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
||||
async def delete_project(
|
||||
project_id: UUID,
|
||||
service: ProjectSvc,
|
||||
user: CurrentUser,
|
||||
) -> None:
|
||||
"""Delete a project permanently (removes Docker container and volume).
|
||||
|
||||
Only the project owner can delete it.
|
||||
"""
|
||||
await service.delete(project_id, user_id=user.id)
|
||||
|
||||
|
||||
@router.post("/{project_id}/archive", response_model=ProjectRead)
|
||||
async def archive_project(
|
||||
project_id: UUID,
|
||||
service: ProjectSvc,
|
||||
user: CurrentUser,
|
||||
) -> Any:
|
||||
"""Archive a project (soft-delete). Only the owner can archive."""
|
||||
return await service.archive(project_id, user_id=user.id)
|
||||
|
||||
|
||||
# Member Management
|
||||
|
||||
|
||||
@router.get("/{project_id}/members", response_model=ProjectMemberList)
|
||||
async def list_members(
|
||||
project_id: UUID,
|
||||
service: ProjectSvc,
|
||||
user: CurrentUser,
|
||||
) -> Any:
|
||||
"""List all members of a project. Requires viewer access."""
|
||||
members = await service.list_members(project_id, user_id=user.id)
|
||||
return ProjectMemberList(items=members, total=len(members))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/members",
|
||||
response_model=ProjectMemberRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def add_member(
|
||||
project_id: UUID,
|
||||
data: ProjectMemberCreate,
|
||||
service: ProjectSvc,
|
||||
user: CurrentUser,
|
||||
) -> Any:
|
||||
"""Add a user to a project. Requires admin role or ownership."""
|
||||
return await service.add_member(project_id, data, inviter_id=user.id)
|
||||
|
||||
|
||||
@router.patch("/{project_id}/members/{user_id}", response_model=ProjectMemberRead)
|
||||
async def update_member(
|
||||
project_id: UUID,
|
||||
user_id: UUID,
|
||||
data: ProjectMemberUpdate,
|
||||
service: ProjectSvc,
|
||||
user: CurrentUser,
|
||||
) -> Any:
|
||||
"""Update a member's role. Requires admin role or ownership."""
|
||||
return await service.update_member(project_id, user_id, data, requester_id=user.id)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{project_id}/members/{user_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_model=None,
|
||||
)
|
||||
async def remove_member(
|
||||
project_id: UUID,
|
||||
user_id: UUID,
|
||||
service: ProjectSvc,
|
||||
user: CurrentUser,
|
||||
) -> None:
|
||||
"""Remove a member from a project.
|
||||
|
||||
Members can remove themselves. Admin/owner can remove anyone.
|
||||
"""
|
||||
await service.remove_member(project_id, user_id, requester_id=user.id)
|
||||
@@ -0,0 +1,370 @@
|
||||
"""RAG API routes — collection management, search, document upload, sync, status stream.
|
||||
|
||||
Routes are HTTP plumbing only. Business logic, file I/O, task dispatch, and Redis
|
||||
pub/sub all live in their respective services. Domain exceptions raised by services are
|
||||
mapped to HTTP responses by the global exception handlers in
|
||||
``app.api.exception_handlers``; routes do not catch and re-wrap them.
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncIterable
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, File, Query, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.sse import EventSourceResponse, ServerSentEvent
|
||||
|
||||
from app.api.deps import (
|
||||
CurrentAdmin,
|
||||
CurrentUser,
|
||||
IngestionSvc,
|
||||
RAGDocumentSvc,
|
||||
RAGStatusSvc,
|
||||
RAGSyncSvc,
|
||||
RetrievalSvc,
|
||||
SyncSourceSvc,
|
||||
VectorStoreSvc,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.core.exceptions import NotFoundError
|
||||
from app.schemas.rag import (
|
||||
RAGCollectionInfo,
|
||||
RAGCollectionList,
|
||||
RAGDocumentList,
|
||||
RAGIngestResponse,
|
||||
RAGMessageResponse,
|
||||
RAGRetryResponse,
|
||||
RAGSearchRequest,
|
||||
RAGSearchResponse,
|
||||
RAGSearchResult,
|
||||
RAGSyncLogList,
|
||||
RAGSyncRequest,
|
||||
RAGSyncResponse,
|
||||
RAGTrackedDocumentList,
|
||||
)
|
||||
from app.schemas.sync_source import (
|
||||
ConnectorList,
|
||||
SyncSourceCreate,
|
||||
SyncSourceList,
|
||||
SyncSourceRead,
|
||||
SyncSourceUpdate,
|
||||
)
|
||||
from app.services.rag.config import get_supported_formats
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/supported-formats")
|
||||
async def get_supported_formats_endpoint() -> Any:
|
||||
"""Return file formats supported by the current PDF parser configuration."""
|
||||
parser_name = getattr(settings, "PDF_PARSER", "pymupdf")
|
||||
return {"parser": parser_name, "formats": sorted(get_supported_formats(parser_name))}
|
||||
|
||||
|
||||
@router.get("/collections", response_model=RAGCollectionList)
|
||||
async def list_collections(
|
||||
vector_store: VectorStoreSvc,
|
||||
_: CurrentAdmin,
|
||||
) -> Any:
|
||||
"""List all available collections in the vector store."""
|
||||
names = await vector_store.list_collections()
|
||||
return RAGCollectionList(items=names)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/collections/{name}",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
response_model=RAGMessageResponse,
|
||||
)
|
||||
async def create_collection(
|
||||
name: str,
|
||||
vector_store: VectorStoreSvc,
|
||||
_: CurrentAdmin,
|
||||
) -> Any:
|
||||
"""Create and initialize a new collection."""
|
||||
await vector_store.create_collection(name)
|
||||
return RAGMessageResponse(message=f"Collection '{name}' created successfully.")
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/collections/{name}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_model=None,
|
||||
)
|
||||
async def drop_collection(
|
||||
name: str,
|
||||
vector_store: VectorStoreSvc,
|
||||
rag_doc_svc: RAGDocumentSvc,
|
||||
_: CurrentAdmin,
|
||||
) -> None:
|
||||
"""Drop an entire collection — vectors and all SQL document records."""
|
||||
await vector_store.delete_collection(name)
|
||||
await rag_doc_svc.delete_by_collection(name)
|
||||
|
||||
|
||||
@router.get("/collections/{name}/info", response_model=RAGCollectionInfo)
|
||||
async def get_collection_info(
|
||||
name: str,
|
||||
vector_store: VectorStoreSvc,
|
||||
_: CurrentAdmin,
|
||||
) -> Any:
|
||||
"""Retrieve stats for a specific collection."""
|
||||
return await vector_store.get_collection_info(name)
|
||||
|
||||
|
||||
@router.get("/collections/{name}/documents", response_model=RAGDocumentList)
|
||||
async def list_documents(
|
||||
name: str,
|
||||
vector_store: VectorStoreSvc,
|
||||
_: CurrentAdmin,
|
||||
) -> Any:
|
||||
"""List all documents in a specific collection."""
|
||||
return await vector_store.get_document_list(name)
|
||||
|
||||
|
||||
@router.post("/search", response_model=RAGSearchResponse)
|
||||
async def search_documents(
|
||||
request: RAGSearchRequest,
|
||||
retrieval_service: RetrievalSvc,
|
||||
_: CurrentUser,
|
||||
use_reranker: bool = Query(False, description="Whether to use reranking (if configured)"),
|
||||
) -> Any:
|
||||
"""Search for relevant document chunks. Supports multi-collection search."""
|
||||
if request.collection_names and len(request.collection_names) > 1:
|
||||
results = await retrieval_service.retrieve_multi(
|
||||
query=request.query,
|
||||
collection_names=request.collection_names,
|
||||
limit=request.limit,
|
||||
min_score=request.min_score,
|
||||
use_reranker=use_reranker,
|
||||
)
|
||||
else:
|
||||
collection = (
|
||||
request.collection_names[0] if request.collection_names else request.collection_name
|
||||
)
|
||||
results = await retrieval_service.retrieve(
|
||||
query=request.query,
|
||||
collection_name=collection,
|
||||
limit=request.limit,
|
||||
min_score=request.min_score,
|
||||
filter=request.filter or "",
|
||||
use_reranker=use_reranker,
|
||||
)
|
||||
api_results = [RAGSearchResult(**hit.model_dump()) for hit in results]
|
||||
return RAGSearchResponse(results=api_results)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/collections/{name}/documents/{document_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_model=None,
|
||||
)
|
||||
async def delete_document(
|
||||
name: str,
|
||||
document_id: str,
|
||||
ingestion_service: IngestionSvc,
|
||||
_: CurrentAdmin,
|
||||
) -> None:
|
||||
"""Delete a specific document by its ID from a collection."""
|
||||
success = await ingestion_service.remove_document(name, document_id)
|
||||
if not success:
|
||||
raise NotFoundError(
|
||||
message="Document not found",
|
||||
details={"collection": name, "document_id": document_id},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/collections/{name}/ingest",
|
||||
response_model=RAGIngestResponse,
|
||||
response_model_exclude_none=True,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
async def ingest_file(
|
||||
name: str,
|
||||
rag_doc_svc: RAGDocumentSvc,
|
||||
vector_store: VectorStoreSvc,
|
||||
_: CurrentAdmin,
|
||||
file: UploadFile = File(...),
|
||||
replace: bool = Query(False),
|
||||
) -> Any:
|
||||
"""Upload and queue a file for ingestion into a collection."""
|
||||
data = await file.read()
|
||||
return await rag_doc_svc.dispatch_upload(
|
||||
collection_name=name,
|
||||
file_data=data,
|
||||
filename=file.filename or "unknown",
|
||||
replace=replace,
|
||||
vector_store=vector_store,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/documents", response_model=RAGTrackedDocumentList)
|
||||
async def list_rag_documents(
|
||||
rag_doc_svc: RAGDocumentSvc,
|
||||
_: CurrentAdmin,
|
||||
collection_name: str | None = Query(None),
|
||||
) -> Any:
|
||||
"""List tracked RAG documents."""
|
||||
return await rag_doc_svc.list_documents(collection_name)
|
||||
|
||||
|
||||
@router.get("/documents/{doc_id}/download")
|
||||
async def download_rag_document(
|
||||
doc_id: str,
|
||||
rag_doc_svc: RAGDocumentSvc,
|
||||
_: CurrentAdmin,
|
||||
) -> Any:
|
||||
"""Download the original file."""
|
||||
file_path, filename, mime_type = await rag_doc_svc.get_download_info(doc_id)
|
||||
return FileResponse(path=file_path, filename=filename, media_type=mime_type)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/documents/{doc_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_model=None,
|
||||
)
|
||||
async def delete_rag_document(
|
||||
doc_id: str,
|
||||
rag_doc_svc: RAGDocumentSvc,
|
||||
ingestion_service: IngestionSvc,
|
||||
_: CurrentAdmin,
|
||||
) -> None:
|
||||
"""Delete a document from SQL, vector store, and file storage."""
|
||||
await rag_doc_svc.delete_document(doc_id, ingestion_service)
|
||||
|
||||
|
||||
@router.post("/documents/{doc_id}/retry", response_model=RAGRetryResponse)
|
||||
async def retry_ingestion(
|
||||
doc_id: str,
|
||||
rag_doc_svc: RAGDocumentSvc,
|
||||
_: CurrentAdmin,
|
||||
) -> Any:
|
||||
"""Retry a failed document ingestion."""
|
||||
doc = await rag_doc_svc.retry_ingestion(doc_id)
|
||||
return RAGRetryResponse(id=str(doc.id), status="processing", message="Retry queued")
|
||||
|
||||
|
||||
@router.get("/sync/logs", response_model=RAGSyncLogList)
|
||||
async def list_sync_logs(
|
||||
rag_sync_svc: RAGSyncSvc,
|
||||
_: CurrentAdmin,
|
||||
collection_name: str | None = Query(None),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
) -> Any:
|
||||
"""List sync operation logs."""
|
||||
return await rag_sync_svc.list_sync_logs(collection_name=collection_name, limit=limit)
|
||||
|
||||
|
||||
@router.post("/sync/local", response_model=RAGSyncResponse)
|
||||
async def trigger_local_sync(
|
||||
request: RAGSyncRequest,
|
||||
rag_sync_svc: RAGSyncSvc,
|
||||
_: CurrentAdmin,
|
||||
) -> Any:
|
||||
"""Trigger a local directory sync via background task."""
|
||||
sync_log = await rag_sync_svc.start_local_sync(
|
||||
collection_name=request.collection_name,
|
||||
mode=request.mode,
|
||||
path=request.path,
|
||||
)
|
||||
return RAGSyncResponse(
|
||||
id=str(sync_log.id),
|
||||
status="running",
|
||||
message=f"Sync started for '{request.collection_name}' (mode={request.mode})",
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/sync/{sync_id}", response_model=RAGMessageResponse)
|
||||
async def cancel_sync(
|
||||
sync_id: str,
|
||||
rag_sync_svc: RAGSyncSvc,
|
||||
_: CurrentAdmin,
|
||||
) -> Any:
|
||||
"""Cancel a running sync operation."""
|
||||
await rag_sync_svc.cancel_sync(sync_id)
|
||||
return RAGMessageResponse(message="Sync cancelled")
|
||||
|
||||
|
||||
@router.get("/sync/sources", response_model=SyncSourceList)
|
||||
async def list_sync_sources(
|
||||
sync_source_svc: SyncSourceSvc,
|
||||
_: CurrentAdmin,
|
||||
) -> Any:
|
||||
"""List all configured sync sources."""
|
||||
return await sync_source_svc.list_sources()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sync/sources",
|
||||
response_model=SyncSourceRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_sync_source(
|
||||
data: SyncSourceCreate,
|
||||
sync_source_svc: SyncSourceSvc,
|
||||
_: CurrentAdmin,
|
||||
) -> Any:
|
||||
"""Create a new sync source configuration."""
|
||||
return await sync_source_svc.create_source(data)
|
||||
|
||||
|
||||
@router.patch("/sync/sources/{source_id}", response_model=SyncSourceRead)
|
||||
async def update_sync_source(
|
||||
source_id: str,
|
||||
data: SyncSourceUpdate,
|
||||
sync_source_svc: SyncSourceSvc,
|
||||
_: CurrentAdmin,
|
||||
) -> Any:
|
||||
"""Update an existing sync source configuration."""
|
||||
return await sync_source_svc.update_source(source_id, data)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/sync/sources/{source_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_model=None,
|
||||
)
|
||||
async def delete_sync_source(
|
||||
source_id: str,
|
||||
sync_source_svc: SyncSourceSvc,
|
||||
_: CurrentAdmin,
|
||||
) -> None:
|
||||
"""Delete a sync source configuration."""
|
||||
await sync_source_svc.delete_source(source_id)
|
||||
|
||||
|
||||
@router.post("/sync/sources/{source_id}/trigger", response_model=RAGSyncResponse)
|
||||
async def trigger_sync_source(
|
||||
source_id: str,
|
||||
sync_source_svc: SyncSourceSvc,
|
||||
_: CurrentAdmin,
|
||||
) -> Any:
|
||||
"""Trigger a manual sync for a configured source."""
|
||||
sync_log = await sync_source_svc.trigger_sync(source_id)
|
||||
return RAGSyncResponse(
|
||||
id=str(sync_log.id),
|
||||
status="running",
|
||||
message=f"Sync triggered for source '{source_id}'",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sync/connectors", response_model=ConnectorList)
|
||||
async def list_connectors(
|
||||
sync_source_svc: SyncSourceSvc,
|
||||
_: CurrentAdmin,
|
||||
) -> Any:
|
||||
"""List available sync connector types with their config schemas."""
|
||||
return sync_source_svc.list_connectors()
|
||||
|
||||
|
||||
@router.get("/status/stream", response_class=EventSourceResponse)
|
||||
async def rag_status_stream(
|
||||
rag_status_svc: RAGStatusSvc,
|
||||
) -> AsyncIterable[ServerSentEvent]:
|
||||
"""SSE endpoint for real-time RAG ingestion status updates.
|
||||
|
||||
Subscribes to the ``rag_status`` Redis pub/sub channel; the browser auto-reconnects
|
||||
via the EventSource API.
|
||||
"""
|
||||
return rag_status_svc.stream_events()
|
||||
@@ -0,0 +1,133 @@
|
||||
# ruff: noqa: I001 - Imports structured for Jinja2 template conditionals
|
||||
"""User management routes."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi_pagination import Page
|
||||
|
||||
from app.api.deps import (
|
||||
CurrentAdmin,
|
||||
CurrentUser,
|
||||
UserSvc,
|
||||
)
|
||||
from app.db.models.user import UserRole
|
||||
from app.schemas.user import UserRead, UserUpdate
|
||||
from app.services.file_storage import get_file_storage
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserRead)
|
||||
async def read_current_user(
|
||||
current_user: CurrentUser,
|
||||
) -> Any:
|
||||
"""Get current user.
|
||||
|
||||
Returns the authenticated user's profile including their role.
|
||||
"""
|
||||
return current_user
|
||||
|
||||
|
||||
@router.patch("/me", response_model=UserRead)
|
||||
async def update_current_user(
|
||||
user_in: UserUpdate,
|
||||
current_user: CurrentUser,
|
||||
user_service: UserSvc,
|
||||
) -> Any:
|
||||
"""Update current user.
|
||||
|
||||
Users can update their own profile (email, full_name).
|
||||
Role changes require admin privileges.
|
||||
"""
|
||||
# Prevent non-admin users from changing their own role
|
||||
if user_in.role is not None and not current_user.has_role(UserRole.ADMIN):
|
||||
user_in.role = None
|
||||
user = await user_service.update(current_user.id, user_in)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/me/avatar", response_model=UserRead)
|
||||
async def upload_avatar(
|
||||
user_service: UserSvc,
|
||||
current_user: CurrentUser,
|
||||
file: UploadFile = File(...),
|
||||
) -> Any:
|
||||
"""Upload or replace avatar image for the current user."""
|
||||
data = await file.read()
|
||||
try:
|
||||
user = await user_service.update_avatar(
|
||||
current_user.id, data, file.filename or "avatar.jpg", file.content_type or ""
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from None
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/avatar/{user_id}")
|
||||
async def get_avatar(user_id: UUID, user_service: UserSvc) -> Any:
|
||||
"""Get user avatar image."""
|
||||
user = await user_service.get_by_id(user_id)
|
||||
if not user.avatar_url:
|
||||
raise HTTPException(status_code=404, detail="No avatar set")
|
||||
storage = get_file_storage()
|
||||
file_path = storage.get_full_path(user.avatar_url)
|
||||
if not file_path:
|
||||
raise HTTPException(status_code=404, detail="Avatar file not found")
|
||||
return FileResponse(path=file_path, media_type="image/jpeg")
|
||||
|
||||
|
||||
@router.get("", response_model=Page[UserRead])
|
||||
async def read_users(
|
||||
user_service: UserSvc,
|
||||
_: CurrentAdmin,
|
||||
) -> Any:
|
||||
"""Get all users (admin only)."""
|
||||
return await user_service.list_paginated()
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserRead)
|
||||
async def read_user(
|
||||
user_id: UUID,
|
||||
user_service: UserSvc,
|
||||
_: CurrentAdmin,
|
||||
) -> Any:
|
||||
"""Get user by ID (admin only).
|
||||
|
||||
Raises NotFoundError if user does not exist.
|
||||
"""
|
||||
user = await user_service.get_by_id(user_id)
|
||||
return user
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserRead)
|
||||
async def update_user_by_id(
|
||||
user_id: UUID,
|
||||
user_in: UserUpdate,
|
||||
user_service: UserSvc,
|
||||
_: CurrentAdmin,
|
||||
) -> Any:
|
||||
"""Update user by ID (admin only).
|
||||
|
||||
Admins can update any user including their role.
|
||||
|
||||
Raises NotFoundError if user does not exist.
|
||||
"""
|
||||
user = await user_service.update(user_id, user_in)
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
||||
async def delete_user_by_id(
|
||||
user_id: UUID,
|
||||
user_service: UserSvc,
|
||||
_: CurrentAdmin,
|
||||
) -> None:
|
||||
"""Delete user by ID (admin only).
|
||||
|
||||
Raises NotFoundError if user does not exist.
|
||||
"""
|
||||
await user_service.delete(user_id)
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Webhook management routes."""
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Query, status
|
||||
|
||||
from app.api.deps import CurrentAdmin, WebhookSvc
|
||||
from app.schemas.webhook import (
|
||||
WebhookCreate,
|
||||
WebhookDeliveryListResponse,
|
||||
WebhookDeliveryRead,
|
||||
WebhookListResponse,
|
||||
WebhookRead,
|
||||
WebhookSecretResponse,
|
||||
WebhookTestResponse,
|
||||
WebhookUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("", response_model=WebhookRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_webhook(
|
||||
data: WebhookCreate,
|
||||
webhook_service: WebhookSvc,
|
||||
current_user: CurrentAdmin,
|
||||
) -> Any:
|
||||
"""Create a new webhook subscription."""
|
||||
webhook = await webhook_service.create_webhook(
|
||||
data,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
return WebhookRead(
|
||||
id=webhook.id,
|
||||
name=webhook.name,
|
||||
url=webhook.url,
|
||||
events=webhook.events,
|
||||
is_active=webhook.is_active,
|
||||
description=webhook.description,
|
||||
created_at=webhook.created_at,
|
||||
updated_at=webhook.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=WebhookListResponse)
|
||||
async def list_webhooks(
|
||||
webhook_service: WebhookSvc,
|
||||
current_user: CurrentAdmin,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
) -> Any:
|
||||
"""List all webhooks."""
|
||||
webhooks, total = await webhook_service.list_webhooks(
|
||||
user_id=current_user.id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
return WebhookListResponse(
|
||||
items=[
|
||||
WebhookRead(
|
||||
id=w.id,
|
||||
name=w.name,
|
||||
url=w.url,
|
||||
events=w.events,
|
||||
is_active=w.is_active,
|
||||
description=w.description,
|
||||
created_at=w.created_at,
|
||||
updated_at=w.updated_at,
|
||||
)
|
||||
for w in webhooks
|
||||
],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{webhook_id}", response_model=WebhookRead)
|
||||
async def get_webhook(
|
||||
webhook_id: UUID,
|
||||
webhook_service: WebhookSvc,
|
||||
_: CurrentAdmin,
|
||||
) -> Any:
|
||||
"""Get a webhook by ID."""
|
||||
webhook = await webhook_service.get_webhook(webhook_id)
|
||||
return WebhookRead(
|
||||
id=webhook.id,
|
||||
name=webhook.name,
|
||||
url=webhook.url,
|
||||
events=webhook.events,
|
||||
is_active=webhook.is_active,
|
||||
description=webhook.description,
|
||||
created_at=webhook.created_at,
|
||||
updated_at=webhook.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{webhook_id}", response_model=WebhookRead)
|
||||
async def update_webhook(
|
||||
webhook_id: UUID,
|
||||
data: WebhookUpdate,
|
||||
webhook_service: WebhookSvc,
|
||||
_: CurrentAdmin,
|
||||
) -> Any:
|
||||
"""Update a webhook."""
|
||||
webhook = await webhook_service.update_webhook(webhook_id, data)
|
||||
return WebhookRead(
|
||||
id=webhook.id,
|
||||
name=webhook.name,
|
||||
url=webhook.url,
|
||||
events=webhook.events,
|
||||
is_active=webhook.is_active,
|
||||
description=webhook.description,
|
||||
created_at=webhook.created_at,
|
||||
updated_at=webhook.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{webhook_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
||||
async def delete_webhook(
|
||||
webhook_id: UUID,
|
||||
webhook_service: WebhookSvc,
|
||||
_: CurrentAdmin,
|
||||
) -> None:
|
||||
"""Delete a webhook."""
|
||||
await webhook_service.delete_webhook(webhook_id)
|
||||
|
||||
|
||||
@router.post("/{webhook_id}/test", response_model=WebhookTestResponse)
|
||||
async def test_webhook(
|
||||
webhook_id: UUID,
|
||||
webhook_service: WebhookSvc,
|
||||
_: CurrentAdmin,
|
||||
) -> Any:
|
||||
"""Send a test event to the webhook."""
|
||||
result = await webhook_service.test_webhook(webhook_id)
|
||||
return WebhookTestResponse(**result)
|
||||
|
||||
|
||||
@router.post("/{webhook_id}/regenerate-secret", response_model=WebhookSecretResponse)
|
||||
async def regenerate_webhook_secret(
|
||||
webhook_id: UUID,
|
||||
webhook_service: WebhookSvc,
|
||||
_: CurrentAdmin,
|
||||
) -> Any:
|
||||
"""Regenerate the webhook secret."""
|
||||
new_secret = await webhook_service.regenerate_secret(webhook_id)
|
||||
return WebhookSecretResponse(secret=new_secret)
|
||||
|
||||
|
||||
@router.get("/{webhook_id}/deliveries", response_model=WebhookDeliveryListResponse)
|
||||
async def list_webhook_deliveries(
|
||||
webhook_id: UUID,
|
||||
webhook_service: WebhookSvc,
|
||||
_: CurrentAdmin,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
) -> Any:
|
||||
"""Get delivery history for a webhook."""
|
||||
deliveries, total = await webhook_service.get_deliveries(webhook_id, skip=skip, limit=limit)
|
||||
return WebhookDeliveryListResponse(
|
||||
items=[
|
||||
WebhookDeliveryRead(
|
||||
id=d.id,
|
||||
webhook_id=d.webhook_id,
|
||||
event_type=d.event_type,
|
||||
response_status=d.response_status,
|
||||
error_message=d.error_message,
|
||||
attempt_count=d.attempt_count,
|
||||
success=d.success,
|
||||
created_at=d.created_at,
|
||||
delivered_at=d.delivered_at,
|
||||
)
|
||||
for d in deliveries
|
||||
],
|
||||
total=total,
|
||||
)
|
||||
@@ -0,0 +1,219 @@
|
||||
"""API versioning utilities and deprecation handling.
|
||||
|
||||
This module provides tools for managing API version deprecation:
|
||||
- Deprecation middleware for entire API versions
|
||||
- Deprecation decorator for individual endpoints
|
||||
- RFC 8594 compliant deprecation headers
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from functools import wraps
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VersionDeprecationMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware to add deprecation headers for deprecated API versions.
|
||||
|
||||
Adds RFC 8594 compliant headers:
|
||||
- Deprecation: Indicates the version is deprecated
|
||||
- Sunset: Indicates when the version will be removed
|
||||
- Link: Points to migration documentation
|
||||
|
||||
Usage in main.py:
|
||||
app.add_middleware(
|
||||
VersionDeprecationMiddleware,
|
||||
deprecated_versions={"v1": {"sunset": "2025-06-01", "link": "/docs/migration/v2"}},
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app: ASGIApp,
|
||||
deprecated_versions: dict[str, dict[str, str]] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the middleware.
|
||||
|
||||
Args:
|
||||
app: The ASGI application.
|
||||
deprecated_versions: Dict mapping version prefixes to deprecation info.
|
||||
Each entry should have:
|
||||
- sunset: ISO date string when version will be removed (optional)
|
||||
- link: URL to migration documentation (optional)
|
||||
- message: Custom deprecation message (optional)
|
||||
|
||||
Example:
|
||||
{
|
||||
"v1": {
|
||||
"sunset": "2025-06-01",
|
||||
"link": "https://api.example.com/docs/migration/v2",
|
||||
"message": "Please migrate to API v2",
|
||||
}
|
||||
}
|
||||
"""
|
||||
super().__init__(app)
|
||||
self.deprecated_versions = deprecated_versions or {}
|
||||
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
"""Process the request and add deprecation headers if needed."""
|
||||
response = await call_next(request)
|
||||
|
||||
# Check if request path matches a deprecated version
|
||||
path = request.url.path
|
||||
for version, info in self.deprecated_versions.items():
|
||||
if f"/api/{version}/" in path or path.endswith(f"/api/{version}"):
|
||||
self._add_deprecation_headers(response, version, info)
|
||||
self._log_deprecated_usage(request, version)
|
||||
break
|
||||
|
||||
return response
|
||||
|
||||
def _add_deprecation_headers(
|
||||
self, response: Response, version: str, info: dict[str, str]
|
||||
) -> None:
|
||||
"""Add RFC 8594 deprecation headers to the response."""
|
||||
# Deprecation header - indicates the API is deprecated
|
||||
response.headers["Deprecation"] = "true"
|
||||
|
||||
# Sunset header - when the API will be removed
|
||||
if sunset := info.get("sunset"):
|
||||
# Convert to HTTP date format
|
||||
sunset_date = datetime.fromisoformat(sunset)
|
||||
response.headers["Sunset"] = sunset_date.strftime("%a, %d %b %Y %H:%M:%S GMT")
|
||||
|
||||
# Link header - documentation for migration
|
||||
if link := info.get("link"):
|
||||
response.headers["Link"] = f'<{link}>; rel="deprecation"'
|
||||
|
||||
# Custom warning header
|
||||
message = info.get("message", f"API {version} is deprecated")
|
||||
response.headers["X-API-Deprecation-Warning"] = message
|
||||
|
||||
def _log_deprecated_usage(self, request: Request, version: str) -> None:
|
||||
"""Log usage of deprecated API version for monitoring."""
|
||||
logger.warning(
|
||||
"Deprecated API version accessed: %s %s %s",
|
||||
version,
|
||||
request.method,
|
||||
request.url.path,
|
||||
)
|
||||
|
||||
|
||||
def deprecated(
|
||||
sunset: str | None = None,
|
||||
message: str | None = None,
|
||||
link: str | None = None,
|
||||
) -> Callable[..., Any]:
|
||||
"""Decorator to mark an endpoint as deprecated.
|
||||
|
||||
Adds deprecation headers to responses from the decorated endpoint.
|
||||
Use this for deprecating individual endpoints within an active API version.
|
||||
|
||||
Args:
|
||||
sunset: ISO date string when endpoint will be removed.
|
||||
message: Custom deprecation message.
|
||||
link: URL to migration documentation.
|
||||
|
||||
Usage:
|
||||
@router.get("/old-endpoint")
|
||||
@deprecated(
|
||||
sunset="2025-06-01",
|
||||
message="Use /new-endpoint instead",
|
||||
link="/docs/migration",
|
||||
)
|
||||
async def old_endpoint():
|
||||
...
|
||||
"""
|
||||
|
||||
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
@wraps(func)
|
||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
# Get the response from the endpoint
|
||||
result = await func(*args, **kwargs)
|
||||
|
||||
# Find Response object in args (FastAPI injects it)
|
||||
response = None
|
||||
for arg in args:
|
||||
if isinstance(arg, Response):
|
||||
response = arg
|
||||
break
|
||||
for value in kwargs.values():
|
||||
if isinstance(value, Response):
|
||||
response = value
|
||||
break
|
||||
|
||||
# If we have a Response object, add headers
|
||||
if response:
|
||||
response.headers["Deprecation"] = "true"
|
||||
if sunset:
|
||||
sunset_date = datetime.fromisoformat(sunset)
|
||||
response.headers["Sunset"] = sunset_date.strftime("%a, %d %b %Y %H:%M:%S GMT")
|
||||
if link:
|
||||
response.headers["Link"] = f'<{link}>; rel="deprecation"'
|
||||
if message:
|
||||
response.headers["X-API-Deprecation-Warning"] = message
|
||||
|
||||
return result
|
||||
|
||||
# Add deprecation info to OpenAPI schema
|
||||
wrapper.__doc__ = (
|
||||
f"{func.__doc__ or ''}\n\n"
|
||||
f"**DEPRECATED**"
|
||||
f"{f': {message}' if message else ''}"
|
||||
f"{f' (Sunset: {sunset})' if sunset else ''}"
|
||||
)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
# Example usage documentation
|
||||
"""
|
||||
## Adding a New API Version
|
||||
|
||||
1. Create a new version folder:
|
||||
```
|
||||
app/api/routes/v2/
|
||||
├── __init__.py
|
||||
├── health.py
|
||||
├── auth.py
|
||||
└── ...
|
||||
```
|
||||
|
||||
2. Create the v2 router in `v2/__init__.py`:
|
||||
```python
|
||||
from fastapi import APIRouter
|
||||
v2_router = APIRouter()
|
||||
# Include routes...
|
||||
```
|
||||
|
||||
3. Add the v2 router in `app/api/router.py`:
|
||||
```python
|
||||
from app.api.routes.v2 import v2_router
|
||||
|
||||
api_router.include_router(v1_router, prefix="/v1")
|
||||
api_router.include_router(v2_router, prefix="/v2")
|
||||
```
|
||||
|
||||
4. Mark v1 as deprecated in `main.py`:
|
||||
```python
|
||||
app.add_middleware(
|
||||
VersionDeprecationMiddleware,
|
||||
deprecated_versions={
|
||||
"v1": {
|
||||
"sunset": "2025-12-31",
|
||||
"link": "/docs/migration/v2",
|
||||
"message": "Please migrate to API v2",
|
||||
}
|
||||
},
|
||||
)
|
||||
```
|
||||
"""
|
||||
@@ -0,0 +1,10 @@
|
||||
"""External service clients.
|
||||
|
||||
This module contains thin wrappers around external services like Redis.
|
||||
"""
|
||||
|
||||
from app.clients.redis import RedisClient
|
||||
|
||||
__all__ = [
|
||||
"RedisClient",
|
||||
]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Redis client wrapper.
|
||||
|
||||
Provides a class-based Redis client for connection management and operations.
|
||||
"""
|
||||
|
||||
from redis import asyncio as aioredis
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class RedisClient:
|
||||
"""Redis client wrapper for connection lifecycle management.
|
||||
|
||||
Usage in FastAPI lifespan:
|
||||
async with contextmanager():
|
||||
redis = RedisClient(settings.REDIS_URL)
|
||||
await redis.connect()
|
||||
yield {"redis": redis}
|
||||
await redis.close()
|
||||
"""
|
||||
|
||||
def __init__(self, url: str | None = None):
|
||||
self.url = url or settings.REDIS_URL
|
||||
self.client: aioredis.Redis | None = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Connect to Redis server."""
|
||||
self.client = aioredis.from_url( # type: ignore[no-untyped-call]
|
||||
self.url,
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close Redis connection."""
|
||||
if self.client:
|
||||
await self.client.close()
|
||||
self.client = None
|
||||
|
||||
async def get(self, key: str) -> str | None:
|
||||
"""Get a value by key."""
|
||||
if not self.client:
|
||||
raise RuntimeError("Redis client not connected")
|
||||
return await self.client.get(key) # type: ignore[no-any-return]
|
||||
|
||||
async def set(
|
||||
self,
|
||||
key: str,
|
||||
value: str,
|
||||
ttl: int | None = None,
|
||||
) -> None:
|
||||
"""Set a value with optional TTL (in seconds)."""
|
||||
if not self.client:
|
||||
raise RuntimeError("Redis client not connected")
|
||||
await self.client.set(key, value, ex=ttl)
|
||||
|
||||
async def delete(self, key: str) -> int:
|
||||
"""Delete a key. Returns number of keys deleted."""
|
||||
if not self.client:
|
||||
raise RuntimeError("Redis client not connected")
|
||||
return await self.client.delete(key) # type: ignore[no-any-return]
|
||||
|
||||
async def exists(self, key: str) -> bool:
|
||||
"""Check if key exists."""
|
||||
if not self.client:
|
||||
raise RuntimeError("Redis client not connected")
|
||||
return bool(await self.client.exists(key))
|
||||
|
||||
async def ping(self) -> bool:
|
||||
"""Ping Redis server. Returns True if connected."""
|
||||
if not self.client:
|
||||
return False
|
||||
try:
|
||||
await self.client.ping()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@property
|
||||
def raw(self) -> aioredis.Redis:
|
||||
"""Access the underlying aioredis client for advanced operations."""
|
||||
if not self.client:
|
||||
raise RuntimeError("Redis client not connected")
|
||||
return self.client
|
||||
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Custom commands system with auto-discovery.
|
||||
|
||||
This module provides a Django-like custom commands system for FastAPI + Click.
|
||||
Commands are auto-discovered from this package and registered to the CLI.
|
||||
|
||||
Usage:
|
||||
# In app/commands/my_command.py
|
||||
from app.commands import command
|
||||
import click
|
||||
|
||||
@command("my-command", help="Description of my command")
|
||||
@click.option("--option", "-o", help="Some option")
|
||||
def my_command(option: str):
|
||||
click.echo(f"Running with {option}")
|
||||
|
||||
# Then use it:
|
||||
# project cmd my-command --option value
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import pkgutil
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
# Registry for custom commands
|
||||
_commands: list[click.Command] = []
|
||||
_discovered = False
|
||||
|
||||
|
||||
def command(name: str | None = None, **kwargs: Any) -> Callable[..., Any]:
|
||||
"""
|
||||
Decorator to register a custom command.
|
||||
|
||||
Args:
|
||||
name: Command name (defaults to function name with underscores replaced by hyphens)
|
||||
**kwargs: Additional arguments passed to click.command()
|
||||
|
||||
Example:
|
||||
@command("seed", help="Seed database with initial data")
|
||||
@click.option("--count", "-c", default=10)
|
||||
def seed_data(count: int):
|
||||
click.echo(f"Seeding {count} records...")
|
||||
"""
|
||||
|
||||
def decorator(func: Callable[..., Any]) -> click.Command:
|
||||
cmd_name = name or func.__name__.replace("_", "-")
|
||||
cmd: click.Command = click.command(cmd_name, **kwargs)(func) # type: ignore[no-untyped-call]
|
||||
_commands.append(cmd)
|
||||
return cmd
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def discover_commands() -> list[click.Command]:
|
||||
"""
|
||||
Auto-discover all commands in this package.
|
||||
|
||||
Imports all modules in the app.commands package (except those starting with _)
|
||||
which triggers the @command decorator to register them.
|
||||
|
||||
Returns:
|
||||
List of discovered click.Command objects
|
||||
"""
|
||||
global _discovered
|
||||
|
||||
if _discovered:
|
||||
return _commands
|
||||
|
||||
package_dir = Path(__file__).parent
|
||||
|
||||
for _, module_name, _ in pkgutil.iter_modules([str(package_dir)]):
|
||||
if module_name.startswith("_"):
|
||||
continue
|
||||
|
||||
try:
|
||||
importlib.import_module(f"app.commands.{module_name}")
|
||||
except ImportError as e:
|
||||
click.secho(
|
||||
f"Warning: Failed to import command module '{module_name}': {e}", fg="yellow"
|
||||
)
|
||||
|
||||
_discovered = True
|
||||
return _commands
|
||||
|
||||
|
||||
def register_commands(cli: click.Group) -> None:
|
||||
"""
|
||||
Register all discovered commands to a CLI group.
|
||||
|
||||
Args:
|
||||
cli: The click.Group to add commands to
|
||||
"""
|
||||
commands = discover_commands()
|
||||
|
||||
for cmd in commands:
|
||||
cli.add_command(cmd)
|
||||
|
||||
|
||||
def success(message: str) -> None:
|
||||
"""Print success message in green."""
|
||||
click.secho(message, fg="green")
|
||||
|
||||
|
||||
def error(message: str) -> None:
|
||||
"""Print error message in red."""
|
||||
click.secho(message, fg="red")
|
||||
|
||||
|
||||
def warning(message: str) -> None:
|
||||
"""Print warning message in yellow."""
|
||||
click.secho(message, fg="yellow")
|
||||
|
||||
|
||||
def info(message: str) -> None:
|
||||
"""Print info message."""
|
||||
click.echo(message)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Cleanup old or stale data from the database.
|
||||
|
||||
This command is useful for maintenance tasks.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import click
|
||||
|
||||
from app.commands import command, info, success, warning
|
||||
|
||||
|
||||
@command("cleanup", help="Clean up old data from the database")
|
||||
@click.option("--days", "-d", default=90, type=int, help="Delete records older than N days")
|
||||
@click.option("--dry-run", is_flag=True, help="Show what would be deleted without making changes")
|
||||
@click.option("--force", "-f", is_flag=True, help="Skip confirmation prompt")
|
||||
def cleanup(days: int, dry_run: bool, force: bool) -> None:
|
||||
"""
|
||||
Remove old records from the database.
|
||||
|
||||
Example:
|
||||
project cmd cleanup --days 90
|
||||
project cmd cleanup --days 30 --dry-run
|
||||
project cmd cleanup --days 7 --force
|
||||
"""
|
||||
cutoff_date = datetime.now(UTC) - timedelta(days=days)
|
||||
|
||||
if dry_run:
|
||||
info(f"[DRY RUN] Would delete records older than {cutoff_date}")
|
||||
return
|
||||
|
||||
if not force and not click.confirm(
|
||||
f"Delete all records older than {days} days ({cutoff_date})?"
|
||||
):
|
||||
warning("Aborted.")
|
||||
return
|
||||
|
||||
async def _cleanup() -> None:
|
||||
info(f"Cleaning up records older than {cutoff_date}...")
|
||||
total_deleted = 0
|
||||
success(f"Done. Total deleted: {total_deleted} rows.")
|
||||
|
||||
asyncio.run(_cleanup())
|
||||
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Example custom command.
|
||||
|
||||
This is a template showing how to create custom CLI commands.
|
||||
Copy this file and modify it to create your own commands.
|
||||
"""
|
||||
|
||||
import click
|
||||
|
||||
from app.commands import command, info, success
|
||||
|
||||
|
||||
@command("hello", help="Example command that greets the user")
|
||||
@click.option("--name", "-n", default="World", help="Name to greet")
|
||||
@click.option("--count", "-c", default=1, type=int, help="Number of greetings")
|
||||
def hello(name: str, count: int) -> None:
|
||||
"""
|
||||
Greet someone multiple times.
|
||||
|
||||
Example:
|
||||
project cmd hello --name Alice --count 3
|
||||
"""
|
||||
info(f"Greeting {name} {count} time(s)...")
|
||||
|
||||
for i in range(count):
|
||||
click.echo(f" [{i + 1}] Hello, {name}!")
|
||||
|
||||
success("Done!")
|
||||
@@ -0,0 +1,638 @@
|
||||
"""
|
||||
RAG CLI commands for document management and retrieval.
|
||||
|
||||
Commands:
|
||||
rag-collections - List collections with stats
|
||||
rag-ingest - Ingest file/directory
|
||||
rag-search - Search knowledge base
|
||||
rag-drop - Drop collection
|
||||
rag-stats - Overall RAG system statistics
|
||||
rag-sources - List configured sync sources
|
||||
rag-source-add - Add a new sync source
|
||||
rag-source-remove - Remove a sync source
|
||||
rag-source-sync - Trigger sync for a source (or all)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from app.commands import command, error, info, success, warning
|
||||
from app.services.rag.config import DocumentExtensions, RAGSettings
|
||||
from app.services.rag.documents import DocumentProcessor
|
||||
from app.services.rag.embeddings import EmbeddingService
|
||||
from app.services.rag.ingestion import IngestionService
|
||||
from app.services.rag.retrieval import RetrievalService
|
||||
from app.services.rag.vectorstore import BaseVectorStore, QdrantVectorStore
|
||||
|
||||
|
||||
def get_rag_services() -> tuple[
|
||||
RAGSettings, BaseVectorStore, DocumentProcessor, RetrievalService, IngestionService
|
||||
]:
|
||||
"""Initialize RAG services for CLI usage.
|
||||
|
||||
Creates and returns all necessary RAG service components:
|
||||
- Settings (RAG configuration)
|
||||
- Vector store (Milvus)
|
||||
- Document processor
|
||||
- Retrieval service
|
||||
- Ingestion service
|
||||
|
||||
Returns:
|
||||
Tuple of (settings, vector_store, processor, retrieval, ingestion) services.
|
||||
"""
|
||||
settings = RAGSettings()
|
||||
embedder = EmbeddingService(settings=settings)
|
||||
vector_store = QdrantVectorStore(settings=settings, embedding_service=embedder)
|
||||
processor = DocumentProcessor(settings=settings)
|
||||
retrieval = RetrievalService(vector_store=vector_store, settings=settings)
|
||||
ingestion = IngestionService(processor=processor, vector_store=vector_store)
|
||||
return settings, vector_store, processor, retrieval, ingestion
|
||||
|
||||
|
||||
async def list_collections_async(vector_store: BaseVectorStore) -> None:
|
||||
"""List all collections with their stats.
|
||||
|
||||
Args:
|
||||
vector_store: The Milvus vector store to query.
|
||||
"""
|
||||
collection_names = await vector_store.list_collections()
|
||||
|
||||
if not collection_names:
|
||||
info("No collections found.")
|
||||
return
|
||||
|
||||
click.echo(f"\nFound {len(collection_names)} collection(s):\n")
|
||||
|
||||
for name in collection_names:
|
||||
try:
|
||||
info_obj = await vector_store.get_collection_info(name)
|
||||
click.echo(f" {name}")
|
||||
click.echo(f" Vectors: {info_obj.total_vectors:,}")
|
||||
click.echo(f" Dimension: {info_obj.dim}")
|
||||
click.echo(f" Status: {info_obj.indexing_status}")
|
||||
click.echo()
|
||||
except Exception as e:
|
||||
warning(f"Could not get info for '{name}': {e}")
|
||||
|
||||
|
||||
@command("rag-collections", help="List collections with stats")
|
||||
def rag_collections() -> None:
|
||||
"""List all available collections in the vector store with their statistics."""
|
||||
_, vector_store, _, _, _ = get_rag_services()
|
||||
asyncio.run(list_collections_async(vector_store))
|
||||
|
||||
|
||||
async def ingest_path_async(
|
||||
path: str,
|
||||
collection: str,
|
||||
recursive: bool,
|
||||
vector_store: BaseVectorStore,
|
||||
processor: DocumentProcessor,
|
||||
ingestion: IngestionService,
|
||||
replace: bool = True,
|
||||
sync_mode: str = "full",
|
||||
) -> None:
|
||||
"""Ingest files from a path (file or directory).
|
||||
|
||||
Args:
|
||||
path: Path to a file or directory to ingest.
|
||||
collection: Target collection name.
|
||||
recursive: Whether to recursively process directories.
|
||||
vector_store: The Milvus vector store.
|
||||
processor: Document processor for parsing files.
|
||||
ingestion: Ingestion service for storing documents.
|
||||
"""
|
||||
target_path = Path(path).resolve()
|
||||
|
||||
if not target_path.exists():
|
||||
error(f"Path does not exist: {target_path}")
|
||||
return
|
||||
|
||||
# Collect files to process
|
||||
if target_path.is_file():
|
||||
files = [target_path]
|
||||
elif target_path.is_dir():
|
||||
if recursive:
|
||||
files = list(target_path.rglob("*"))
|
||||
files = [f for f in files if f.is_file() and not f.name.startswith(".")]
|
||||
else:
|
||||
files = list(target_path.iterdir())
|
||||
files = [f for f in files if f.is_file() and not f.name.startswith(".")]
|
||||
else:
|
||||
error(f"Invalid path: {target_path}")
|
||||
return
|
||||
|
||||
if not files:
|
||||
warning("No files found to ingest.")
|
||||
return
|
||||
|
||||
# Filter by allowed extensions
|
||||
allowed_extensions = {ext.value for ext in DocumentExtensions}
|
||||
files = [f for f in files if f.suffix.lower() in allowed_extensions]
|
||||
|
||||
if not files:
|
||||
warning(f"No supported files found. Allowed: {', '.join(allowed_extensions)}")
|
||||
return
|
||||
|
||||
import hashlib
|
||||
|
||||
from tqdm import tqdm
|
||||
|
||||
from app.db.session import get_db_context
|
||||
from app.services.rag_document import RAGDocumentService
|
||||
from app.services.rag_sync import RAGSyncService
|
||||
|
||||
info(f"Syncing {len(files)} file(s) into '{collection}' (mode={sync_mode})...")
|
||||
|
||||
success_count = 0
|
||||
error_count = 0
|
||||
replaced_count = 0
|
||||
skipped_count = 0
|
||||
# Create SyncLog
|
||||
async with get_db_context() as db:
|
||||
sync_log = await RAGSyncService(db).create_sync_log(
|
||||
source="local", collection_name=collection, mode=sync_mode
|
||||
)
|
||||
sync_log_id = str(sync_log.id)
|
||||
|
||||
with tqdm(files, unit="file", desc="Syncing", ncols=80) as pbar:
|
||||
for filepath in pbar:
|
||||
pbar.set_postfix_str(filepath.name[:30], refresh=True)
|
||||
|
||||
# Sync mode checks
|
||||
source_path = str(filepath.resolve())
|
||||
if sync_mode in ("new_only", "update_only"):
|
||||
existing_id: str | None = await ingestion.find_existing(collection, source_path)
|
||||
|
||||
if sync_mode == "new_only":
|
||||
if existing_id:
|
||||
# File exists — check if content changed via hash
|
||||
file_hash: str = hashlib.sha256(filepath.read_bytes()).hexdigest()
|
||||
existing_hash: str | None = await ingestion.get_existing_hash(
|
||||
collection, source_path
|
||||
)
|
||||
if existing_hash and file_hash == existing_hash:
|
||||
skipped_count += 1
|
||||
continue
|
||||
# Hash changed — will re-ingest below
|
||||
|
||||
elif sync_mode == "update_only":
|
||||
if not existing_id:
|
||||
# Not in collection — skip (update_only ignores new files)
|
||||
skipped_count += 1
|
||||
continue
|
||||
file_hash = hashlib.sha256(filepath.read_bytes()).hexdigest()
|
||||
existing_hash = await ingestion.get_existing_hash(collection, source_path)
|
||||
if existing_hash and file_hash == existing_hash:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Create RAGDocument record in SQL
|
||||
async with get_db_context() as db:
|
||||
rag_doc = await RAGDocumentService(db).create_document(
|
||||
collection_name=collection,
|
||||
filename=filepath.name,
|
||||
filesize=filepath.stat().st_size,
|
||||
filetype=filepath.suffix.lstrip(".").lower(),
|
||||
)
|
||||
doc_id = str(rag_doc.id)
|
||||
|
||||
try:
|
||||
result = await ingestion.ingest_file(
|
||||
filepath=filepath, collection_name=collection, replace=replace
|
||||
)
|
||||
if result.status.value == "done":
|
||||
success_count += 1
|
||||
if result.message and "replaced" in result.message:
|
||||
replaced_count += 1
|
||||
async with get_db_context() as db:
|
||||
await RAGDocumentService(db).complete_ingestion(
|
||||
doc_id, vector_document_id=result.document_id
|
||||
)
|
||||
else:
|
||||
error_count += 1
|
||||
tqdm.write(f" ✗ {filepath.name}: {result.error_message}")
|
||||
async with get_db_context() as db:
|
||||
await RAGDocumentService(db).fail_ingestion(
|
||||
doc_id, error_message=result.error_message or "Unknown error"
|
||||
)
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
tqdm.write(f" ✗ {filepath.name}: {e!s}")
|
||||
async with get_db_context() as db:
|
||||
await RAGDocumentService(db).fail_ingestion(doc_id, error_message=str(e))
|
||||
# Update SyncLog
|
||||
async with get_db_context() as db:
|
||||
await RAGSyncService(db).complete_sync(
|
||||
sync_log_id,
|
||||
status="done" if error_count == 0 else "error",
|
||||
total_files=len(files),
|
||||
ingested=success_count - replaced_count,
|
||||
updated=replaced_count,
|
||||
skipped=skipped_count,
|
||||
failed=error_count,
|
||||
)
|
||||
|
||||
click.echo()
|
||||
msg = f"Done: {success_count} ingested"
|
||||
if replaced_count > 0:
|
||||
msg += f" ({replaced_count} updated)"
|
||||
if skipped_count > 0:
|
||||
msg += f", {skipped_count} skipped"
|
||||
success(msg)
|
||||
if error_count > 0:
|
||||
error(f"Failed: {error_count} files")
|
||||
|
||||
|
||||
@command("rag-ingest", help="Ingest file/directory into knowledge base")
|
||||
@click.argument("path", type=click.Path(exists=True))
|
||||
@click.option(
|
||||
"--collection",
|
||||
"-c",
|
||||
default="documents",
|
||||
help="Collection name (default: documents)",
|
||||
)
|
||||
@click.option(
|
||||
"--recursive/--no-recursive",
|
||||
"-r",
|
||||
default=False,
|
||||
help="Recursively process directories (default: False)",
|
||||
)
|
||||
@click.option(
|
||||
"--replace/--no-replace",
|
||||
default=True,
|
||||
help="Replace existing documents with same source path (default: True)",
|
||||
)
|
||||
@click.option(
|
||||
"--sync-mode",
|
||||
type=click.Choice(["full", "new_only", "update_only"]),
|
||||
default="full",
|
||||
help="Sync mode: full (replace all), new_only (add new + update changed), update_only (only update changed, skip new)",
|
||||
)
|
||||
def rag_ingest(path: str, collection: str, recursive: bool, replace: bool, sync_mode: str) -> None:
|
||||
"""
|
||||
Ingest a file or directory into the knowledge base.
|
||||
|
||||
PATH: Path to a file or directory to ingest.
|
||||
|
||||
Example:
|
||||
project cmd rag-ingest ./docs
|
||||
project cmd rag-ingest ./docs --sync-mode new_only
|
||||
project cmd rag-ingest ./docs --sync-mode update_only
|
||||
"""
|
||||
_, vector_store, processor, _, ingestion = get_rag_services()
|
||||
asyncio.run(
|
||||
ingest_path_async(
|
||||
path, collection, recursive, vector_store, processor, ingestion, replace, sync_mode
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def search_async(
|
||||
query: str,
|
||||
collection: str,
|
||||
top_k: int,
|
||||
retrieval: RetrievalService,
|
||||
) -> None:
|
||||
"""Search the knowledge base.
|
||||
|
||||
Args:
|
||||
query: The search query.
|
||||
collection: Target collection name.
|
||||
top_k: Number of results to return.
|
||||
retrieval: Retrieval service for searching.
|
||||
"""
|
||||
info(f"Searching collection '{collection}' for: \"{query}\"")
|
||||
click.echo()
|
||||
|
||||
results = await retrieval.retrieve(
|
||||
query=query,
|
||||
collection_name=collection,
|
||||
limit=top_k,
|
||||
)
|
||||
|
||||
if not results:
|
||||
warning("No results found.")
|
||||
return
|
||||
|
||||
for i, result in enumerate(results, 1):
|
||||
click.echo(f"--- Result {i} (score: {result.score:.4f}) ---")
|
||||
|
||||
# Show source info if available
|
||||
if result.metadata:
|
||||
filename = result.metadata.get("filename", "Unknown")
|
||||
page_num = result.metadata.get("page_num", "?")
|
||||
click.echo(f"Source: {filename} (page {page_num})")
|
||||
|
||||
# Show content (truncated)
|
||||
content = result.content[:500]
|
||||
if len(result.content) > 500:
|
||||
content += "..."
|
||||
click.echo(content)
|
||||
click.echo()
|
||||
|
||||
|
||||
@command("rag-search", help="Search knowledge base")
|
||||
@click.argument("query")
|
||||
@click.option(
|
||||
"--collection",
|
||||
"-c",
|
||||
default="documents",
|
||||
help="Collection name (default: documents)",
|
||||
)
|
||||
@click.option(
|
||||
"--top-k",
|
||||
"-k",
|
||||
default=4,
|
||||
type=int,
|
||||
help="Number of results to return (default: 4)",
|
||||
)
|
||||
def rag_search(query: str, collection: str, top_k: int) -> None:
|
||||
"""
|
||||
Search the knowledge base for relevant content.
|
||||
|
||||
QUERY: The search query.
|
||||
|
||||
Example:
|
||||
project cmd rag-search "what is fastapi"
|
||||
project cmd rag-search "deployment guide" --collection docs --top-k 10
|
||||
"""
|
||||
_, _, _, retrieval, _ = get_rag_services()
|
||||
asyncio.run(search_async(query, collection, top_k, retrieval))
|
||||
|
||||
|
||||
async def drop_collection_async(collection: str, yes: bool, vector_store: BaseVectorStore) -> None:
|
||||
"""Drop a collection.
|
||||
|
||||
Args:
|
||||
collection: Name of the collection to drop.
|
||||
yes: Whether to skip confirmation prompt.
|
||||
vector_store: The Milvus vector store.
|
||||
"""
|
||||
if not yes:
|
||||
click.confirm(
|
||||
f"Are you sure you want to drop collection '{collection}'? This cannot be undone.",
|
||||
abort=True,
|
||||
)
|
||||
|
||||
try:
|
||||
await vector_store.delete_collection(collection)
|
||||
success(f"Collection '{collection}' dropped successfully.")
|
||||
except Exception as e:
|
||||
error(f"Failed to drop collection: {e}")
|
||||
|
||||
|
||||
@command("rag-drop", help="Drop a collection")
|
||||
@click.argument("collection")
|
||||
@click.option(
|
||||
"--yes",
|
||||
"-y",
|
||||
is_flag=True,
|
||||
help="Skip confirmation prompt",
|
||||
)
|
||||
def rag_drop(collection: str, yes: bool) -> None:
|
||||
"""
|
||||
Drop a collection and all its data.
|
||||
|
||||
COLLECTION: Name of the collection to drop.
|
||||
|
||||
Example:
|
||||
project cmd rag-drop my_collection
|
||||
project cmd rag-drop my_collection --yes
|
||||
"""
|
||||
_, vector_store, _, _, _ = get_rag_services()
|
||||
asyncio.run(drop_collection_async(collection, yes, vector_store))
|
||||
|
||||
|
||||
@command("rag-stats", help="Show overall RAG system statistics")
|
||||
def rag_stats() -> None:
|
||||
"""Display overall RAG system statistics."""
|
||||
settings, vector_store, _, _, _ = get_rag_services()
|
||||
|
||||
asyncio.run(stats_async(settings, vector_store))
|
||||
|
||||
|
||||
async def stats_async(settings: RAGSettings, vector_store: BaseVectorStore) -> None:
|
||||
"""Show RAG system statistics.
|
||||
|
||||
Args:
|
||||
settings: RAG configuration settings.
|
||||
vector_store: The Milvus vector store.
|
||||
"""
|
||||
click.echo("RAG System Statistics")
|
||||
click.echo("=" * 40)
|
||||
|
||||
# Collection info
|
||||
try:
|
||||
collection_names = await vector_store.list_collections()
|
||||
click.echo(f"\nCollections: {len(collection_names)}")
|
||||
except Exception as e:
|
||||
warning(f"Could not list collections: {e}")
|
||||
collection_names = []
|
||||
|
||||
# Configuration
|
||||
click.echo("\nConfiguration:")
|
||||
click.echo(f" Embedding model: {settings.embeddings_config.model}")
|
||||
click.echo(f" Embedding dimension: {settings.embeddings_config.dim}")
|
||||
click.echo(f" Chunk size: {settings.chunk_size}")
|
||||
click.echo(f" Chunk overlap: {settings.chunk_overlap}")
|
||||
click.echo(f" Parser method: {settings.pdf_parser.method}")
|
||||
|
||||
# Per-collection stats
|
||||
if collection_names:
|
||||
click.echo("\nCollection Details:")
|
||||
total_vectors = 0
|
||||
for name in collection_names:
|
||||
try:
|
||||
info_obj = await vector_store.get_collection_info(name)
|
||||
click.echo(f" {name}:")
|
||||
click.echo(f" Vectors: {info_obj.total_vectors:,}")
|
||||
total_vectors += info_obj.total_vectors
|
||||
except Exception:
|
||||
click.echo(f" {name}: Error getting info")
|
||||
|
||||
click.echo(f"\nTotal vectors: {total_vectors:,}")
|
||||
|
||||
click.echo()
|
||||
|
||||
|
||||
@command("rag-sources", help="List configured sync sources")
|
||||
def rag_sources() -> None:
|
||||
"""List all configured sync sources with their status."""
|
||||
from app.db.session import get_db_context
|
||||
|
||||
async def _list() -> None:
|
||||
async with get_db_context() as db:
|
||||
from app.services.sync_source import SyncSourceService
|
||||
|
||||
svc = SyncSourceService(db)
|
||||
sources = await svc.list_sources()
|
||||
|
||||
if not sources:
|
||||
info("No sync sources configured.")
|
||||
return
|
||||
|
||||
click.echo(f"\nFound {len(sources)} sync source(s):\n")
|
||||
for s in sources:
|
||||
status_str = s.last_sync_status or "never"
|
||||
active_str = "active" if s.is_active else "inactive"
|
||||
click.echo(f" [{active_str}] {s.name} (id={s.id})")
|
||||
click.echo(f" Type: {s.connector_type}")
|
||||
click.echo(f" Collection: {s.collection_name}")
|
||||
click.echo(f" Sync mode: {s.sync_mode}")
|
||||
if s.schedule_minutes:
|
||||
click.echo(f" Schedule: every {s.schedule_minutes} min")
|
||||
else:
|
||||
click.echo(" Schedule: manual")
|
||||
click.echo(f" Last sync: {status_str}")
|
||||
if s.last_error:
|
||||
click.echo(f" Last error: {s.last_error}")
|
||||
click.echo()
|
||||
|
||||
asyncio.run(_list())
|
||||
|
||||
|
||||
@command("rag-source-add", help="Add a new sync source")
|
||||
@click.option("--name", required=True, help="Source name")
|
||||
@click.option("--type", "connector_type", required=True, help="Connector type (e.g. gdrive, s3)")
|
||||
@click.option("--collection", required=True, help="Target collection name")
|
||||
@click.option("--config", "config_json", required=True, help="Config JSON string")
|
||||
@click.option(
|
||||
"--sync-mode",
|
||||
default="new_only",
|
||||
type=click.Choice(["full", "new_only", "update_only"]),
|
||||
help="Sync mode",
|
||||
)
|
||||
@click.option(
|
||||
"--schedule",
|
||||
"schedule_minutes",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Schedule interval in minutes (0=manual)",
|
||||
)
|
||||
def rag_source_add(
|
||||
name: str,
|
||||
connector_type: str,
|
||||
collection: str,
|
||||
config_json: str,
|
||||
sync_mode: str,
|
||||
schedule_minutes: int,
|
||||
) -> None:
|
||||
"""
|
||||
Add a new sync source configuration.
|
||||
|
||||
Example:
|
||||
project cmd rag-source-add --name "My Drive" --type gdrive --collection docs \\
|
||||
--config '{"folder_id": "abc123"}' --sync-mode new_only
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
try:
|
||||
config_dict = _json.loads(config_json)
|
||||
except _json.JSONDecodeError as e:
|
||||
error(f"Invalid JSON config: {e}")
|
||||
return
|
||||
|
||||
from app.schemas.sync_source import SyncSourceCreate
|
||||
|
||||
data = SyncSourceCreate(
|
||||
name=name,
|
||||
connector_type=connector_type,
|
||||
collection_name=collection,
|
||||
config=config_dict,
|
||||
sync_mode=sync_mode,
|
||||
schedule_minutes=schedule_minutes if schedule_minutes > 0 else None,
|
||||
)
|
||||
from app.db.session import get_db_context
|
||||
|
||||
async def _create() -> None:
|
||||
async with get_db_context() as db:
|
||||
from app.services.sync_source import SyncSourceService
|
||||
|
||||
svc = SyncSourceService(db)
|
||||
try:
|
||||
source = await svc.create_source(data)
|
||||
success(f"Sync source created: {source.name} (id={source.id})")
|
||||
except ValueError as e:
|
||||
error(f"Failed to create source: {e}")
|
||||
|
||||
asyncio.run(_create())
|
||||
|
||||
|
||||
@command("rag-source-remove", help="Remove a sync source")
|
||||
@click.argument("source_id")
|
||||
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
|
||||
def rag_source_remove(source_id: str, yes: bool) -> None:
|
||||
"""
|
||||
Remove a sync source configuration.
|
||||
|
||||
SOURCE_ID: The ID of the sync source to remove.
|
||||
|
||||
Example:
|
||||
project cmd rag-source-remove abc-123-def
|
||||
"""
|
||||
if not yes:
|
||||
click.confirm(f"Are you sure you want to remove sync source '{source_id}'?", abort=True)
|
||||
from app.db.session import get_db_context
|
||||
|
||||
async def _remove() -> None:
|
||||
async with get_db_context() as db:
|
||||
from app.services.sync_source import SyncSourceService
|
||||
|
||||
svc = SyncSourceService(db)
|
||||
try:
|
||||
await svc.delete_source(source_id)
|
||||
success(f"Sync source '{source_id}' removed.")
|
||||
except Exception as e:
|
||||
error(f"Failed to remove source: {e}")
|
||||
|
||||
asyncio.run(_remove())
|
||||
|
||||
|
||||
@command("rag-source-sync", help="Trigger sync for a source")
|
||||
@click.argument("source_id", required=False)
|
||||
@click.option("--all", "sync_all", is_flag=True, help="Sync all active sources")
|
||||
def rag_source_sync(source_id: str | None, sync_all: bool) -> None:
|
||||
"""
|
||||
Trigger sync for a configured source (or all active sources).
|
||||
|
||||
SOURCE_ID: The ID of the sync source to sync (optional if --all).
|
||||
|
||||
Example:
|
||||
project cmd rag-source-sync abc-123-def
|
||||
project cmd rag-source-sync --all
|
||||
"""
|
||||
if not source_id and not sync_all:
|
||||
error("Provide a SOURCE_ID or use --all to sync all active sources.")
|
||||
return
|
||||
from app.db.session import get_db_context
|
||||
|
||||
async def _sync() -> None:
|
||||
async with get_db_context() as db:
|
||||
from app.services.sync_source import SyncSourceService
|
||||
|
||||
svc = SyncSourceService(db)
|
||||
|
||||
if sync_all:
|
||||
sources = await svc.list_sources(is_active=True)
|
||||
if not sources:
|
||||
warning("No active sync sources found.")
|
||||
return
|
||||
info(f"Triggering sync for {len(sources)} active source(s)...")
|
||||
for s in sources:
|
||||
try:
|
||||
log = await svc.trigger_sync(str(s.id))
|
||||
success(f" {s.name}: sync started (log_id={log.id})")
|
||||
except Exception as e:
|
||||
error(f" {s.name}: failed - {e}")
|
||||
else:
|
||||
try:
|
||||
assert source_id is not None
|
||||
log = await svc.trigger_sync(source_id)
|
||||
success(f"Sync triggered (log_id={log.id})")
|
||||
except Exception as e:
|
||||
error(f"Failed to trigger sync: {e}")
|
||||
|
||||
asyncio.run(_sync())
|
||||
@@ -0,0 +1,113 @@
|
||||
# ruff: noqa: I001 - Imports structured for Jinja2 template conditionals
|
||||
"""
|
||||
Seed database with sample data.
|
||||
|
||||
This command is useful for development and testing.
|
||||
Uses random data generation - install faker for better data:
|
||||
uv add faker --group dev
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import random
|
||||
import string
|
||||
|
||||
import click
|
||||
|
||||
from app.commands import command, info, success, warning
|
||||
|
||||
# Try to import Faker for better data generation
|
||||
try:
|
||||
from faker import Faker
|
||||
|
||||
fake = Faker()
|
||||
HAS_FAKER = True
|
||||
except ImportError:
|
||||
HAS_FAKER = False
|
||||
fake = None
|
||||
|
||||
|
||||
def random_email() -> str:
|
||||
"""Generate a random email address."""
|
||||
if HAS_FAKER:
|
||||
return str(fake.email())
|
||||
random_str = "".join(random.choices(string.ascii_lowercase, k=8))
|
||||
return f"{random_str}@example.com"
|
||||
|
||||
|
||||
def random_name() -> str:
|
||||
"""Generate a random full name."""
|
||||
if HAS_FAKER:
|
||||
return str(fake.name())
|
||||
first_names = ["John", "Jane", "Bob", "Alice", "Charlie", "Diana", "Eve", "Frank"]
|
||||
last_names = ["Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis"]
|
||||
return f"{random.choice(first_names)} {random.choice(last_names)}"
|
||||
|
||||
|
||||
@command("seed", help="Seed database with sample data")
|
||||
@click.option("--count", "-c", default=10, type=int, help="Number of records to create")
|
||||
@click.option("--clear", is_flag=True, help="Clear existing data before seeding")
|
||||
@click.option("--dry-run", is_flag=True, help="Show what would be created without making changes")
|
||||
@click.option("--users/--no-users", default=True, help="Seed users (default: True)")
|
||||
def seed(
|
||||
count: int,
|
||||
clear: bool,
|
||||
dry_run: bool,
|
||||
users: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Seed the database with sample data for development.
|
||||
|
||||
Example:
|
||||
project cmd seed --count 50
|
||||
project cmd seed --clear --count 100
|
||||
project cmd seed --dry-run
|
||||
project cmd seed --no-users # Skip user seeding
|
||||
"""
|
||||
if not HAS_FAKER:
|
||||
warning(
|
||||
"Faker not installed. Using basic random data. For better data: uv add faker --group dev"
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
info(f"[DRY RUN] Would create {count} sample records per entity")
|
||||
if clear:
|
||||
info("[DRY RUN] Would clear existing data first")
|
||||
if users:
|
||||
info("[DRY RUN] Would create users")
|
||||
return
|
||||
from app.db.session import get_db_context
|
||||
from app.services.user import UserService
|
||||
from app.schemas.user import UserCreate
|
||||
|
||||
async def _seed() -> None:
|
||||
async with get_db_context() as db:
|
||||
user_svc = UserService(db)
|
||||
created_counts = {}
|
||||
|
||||
if users:
|
||||
if clear:
|
||||
info("Clearing existing users (except admins)...")
|
||||
await user_svc.delete_non_admins()
|
||||
|
||||
if await user_svc.has_any() and not clear:
|
||||
info("Users already exist. Use --clear to replace them.")
|
||||
else:
|
||||
info(f"Creating {count} sample users...")
|
||||
for _ in range(count):
|
||||
await user_svc.register(
|
||||
UserCreate(
|
||||
email=random_email(),
|
||||
password="password123",
|
||||
full_name=random_name(),
|
||||
)
|
||||
)
|
||||
created_counts["users"] = count
|
||||
|
||||
if created_counts:
|
||||
summary = ", ".join(f"{v} {k}" for k, v in created_counts.items())
|
||||
success(f"Created: {summary}")
|
||||
else:
|
||||
info("No records created.")
|
||||
|
||||
asyncio.run(_seed())
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Core application configuration and utilities."""
|
||||
|
||||
from .config import settings
|
||||
|
||||
__all__ = ["settings"]
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Caching configuration using fastapi-cache2."""
|
||||
|
||||
from fastapi_cache import FastAPICache
|
||||
from fastapi_cache.backends.redis import RedisBackend
|
||||
|
||||
from app.clients.redis import RedisClient
|
||||
|
||||
|
||||
def setup_cache(redis: RedisClient) -> None:
|
||||
"""Initialize FastAPI cache with Redis backend.
|
||||
|
||||
Uses the shared Redis client from lifespan state.
|
||||
"""
|
||||
FastAPICache.init(RedisBackend(redis.raw), prefix="agent_delta:cache:")
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Application configuration using Pydantic BaseSettings."""
|
||||
# ruff: noqa: I001 - Imports structured for Jinja2 template conditionals
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import computed_field, field_validator, ValidationInfo
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
def find_env_file() -> Path | None:
|
||||
"""Find .env file in current or parent directories."""
|
||||
current = Path.cwd()
|
||||
for path in [current, current.parent]:
|
||||
env_file = path / ".env"
|
||||
if env_file.exists():
|
||||
return env_file
|
||||
return None
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=find_env_file(),
|
||||
env_ignore_empty=True,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# === Project ===
|
||||
PROJECT_NAME: str = "agent_delta"
|
||||
API_V1_STR: str = "/api/v1"
|
||||
DEBUG: bool = False
|
||||
ENVIRONMENT: Literal["development", "local", "staging", "production"] = "local"
|
||||
TIMEZONE: str = "UTC" # IANA timezone (e.g. "UTC", "Europe/Warsaw", "America/New_York")
|
||||
MODELS_CACHE_DIR: Path = Path("./models_cache")
|
||||
MEDIA_DIR: Path = Path("./media")
|
||||
MAX_UPLOAD_SIZE_MB: int = 50 # Max file upload size in MB
|
||||
# Soft per-org storage cap surfaced on /billing — not enforced yet (5 GB).
|
||||
STORAGE_SOFT_LIMIT_BYTES: int = 5 * 1024 * 1024 * 1024
|
||||
|
||||
# === Logfire ===
|
||||
LOGFIRE_TOKEN: str | None = None
|
||||
LOGFIRE_SERVICE_NAME: str = "agent_delta"
|
||||
LOGFIRE_ENVIRONMENT: str = "development"
|
||||
|
||||
# === Database (PostgreSQL async) ===
|
||||
POSTGRES_HOST: str = "localhost"
|
||||
POSTGRES_PORT: int = 5432
|
||||
POSTGRES_USER: str = "postgres"
|
||||
POSTGRES_PASSWORD: str = ""
|
||||
POSTGRES_DB: str = "agent_delta"
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def DATABASE_URL(self) -> str:
|
||||
"""Build async PostgreSQL connection URL."""
|
||||
return (
|
||||
f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
|
||||
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
|
||||
)
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def DATABASE_URL_SYNC(self) -> str:
|
||||
"""Build sync PostgreSQL connection URL (for Alembic)."""
|
||||
return (
|
||||
f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
|
||||
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
|
||||
)
|
||||
|
||||
# Pool configuration
|
||||
DB_POOL_SIZE: int = 5
|
||||
DB_MAX_OVERFLOW: int = 10
|
||||
DB_POOL_TIMEOUT: int = 30
|
||||
|
||||
# === Auth (SECRET_KEY for JWT/Session/Admin) ===
|
||||
SECRET_KEY: str = "change-me-in-production-use-openssl-rand-hex-32"
|
||||
|
||||
@field_validator("SECRET_KEY")
|
||||
@classmethod
|
||||
def validate_secret_key(cls, v: str, info: ValidationInfo) -> str:
|
||||
"""Validate SECRET_KEY is secure in production."""
|
||||
if len(v) < 32:
|
||||
raise ValueError("SECRET_KEY must be at least 32 characters long")
|
||||
# Get environment from values if available
|
||||
env = info.data.get("ENVIRONMENT", "local") if info.data else "local"
|
||||
if v == "change-me-in-production-use-openssl-rand-hex-32" and env == "production":
|
||||
raise ValueError(
|
||||
"SECRET_KEY must be changed in production! "
|
||||
"Generate a secure key with: openssl rand -hex 32"
|
||||
)
|
||||
return v
|
||||
|
||||
# === JWT Settings ===
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 # 30 minutes
|
||||
REFRESH_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days
|
||||
ALGORITHM: str = "HS256"
|
||||
|
||||
# Public URL of the frontend; used to build OAuth redirect targets and
|
||||
# Stripe checkout/portal return URLs. Always declared (not gated) because
|
||||
# the billing model_validator references it unconditionally.
|
||||
FRONTEND_URL: str = "http://localhost:3000"
|
||||
|
||||
# === Auth (API Key) ===
|
||||
API_KEY: str = "change-me-in-production"
|
||||
API_KEY_HEADER: str = "X-API-Key"
|
||||
|
||||
@field_validator("API_KEY")
|
||||
@classmethod
|
||||
def validate_api_key(cls, v: str, info: ValidationInfo) -> str:
|
||||
"""Validate API_KEY is set in production."""
|
||||
env = info.data.get("ENVIRONMENT", "local") if info.data else "local"
|
||||
if v == "change-me-in-production" and env == "production":
|
||||
raise ValueError(
|
||||
"API_KEY must be changed in production! "
|
||||
"Generate a secure key with: openssl rand -hex 32"
|
||||
)
|
||||
return v
|
||||
|
||||
# === Redis ===
|
||||
REDIS_HOST: str = "localhost"
|
||||
REDIS_PORT: int = 6379
|
||||
REDIS_PASSWORD: str | None = None
|
||||
REDIS_DB: int = 0
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def REDIS_URL(self) -> str:
|
||||
"""Build Redis connection URL."""
|
||||
if self.REDIS_PASSWORD:
|
||||
return f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
|
||||
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
|
||||
|
||||
# === Rate Limiting ===
|
||||
RATE_LIMIT_REQUESTS: int = 100
|
||||
RATE_LIMIT_PERIOD: int = 60 # seconds
|
||||
|
||||
# === Taskiq ===
|
||||
TASKIQ_BROKER_URL: str = "redis://localhost:6379/1"
|
||||
TASKIQ_RESULT_BACKEND: str = "redis://localhost:6379/1"
|
||||
|
||||
# === AI Agent (pydantic_deep, openai) ===
|
||||
OPENAI_API_KEY: str = ""
|
||||
AI_MODEL: str = "gpt-5.5"
|
||||
AI_TEMPERATURE: float = 0.7
|
||||
AI_THINKING_ENABLED: bool = False
|
||||
AI_THINKING_EFFORT: str = "medium" # "low", "medium", "high"
|
||||
AI_AVAILABLE_MODELS: list[str] = [
|
||||
"gpt-5.5",
|
||||
"gpt-5.5-pro",
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-pro",
|
||||
"gpt-5.4-mini",
|
||||
"gpt-5.4-nano",
|
||||
"gpt-5-mini",
|
||||
"gpt-5-nano",
|
||||
"gpt-5",
|
||||
"gpt-4.1",
|
||||
]
|
||||
AI_FRAMEWORK: str = "pydantic_deep"
|
||||
LLM_PROVIDER: str = "openai"
|
||||
|
||||
# === Web Search (Tavily) ===
|
||||
TAVILY_API_KEY: str = ""
|
||||
|
||||
# === AntV charts (advanced diagrams via mcp-server-chart sidecar) ===
|
||||
# Opt-in at runtime: the code ships, but stays off until you flip this on
|
||||
# and start the antvis-chart sidecar (docker compose --profile antv).
|
||||
ENABLE_ANTV_CHARTS: bool = False
|
||||
# MCP endpoint of the antvis-chart sidecar (streamable HTTP)
|
||||
ANTV_MCP_URL: str = "http://antvis-chart:1122/mcp"
|
||||
# Optional self-hosted GPT-Vis render backend (empty = AntV public service)
|
||||
ANTV_VIS_REQUEST_SERVER: str = ""
|
||||
# Comma-separated AntV tools to disable — defaults (set on the sidecar) drop
|
||||
# the basic charts that overlap create_chart and the China-only maps.
|
||||
ANTV_DISABLED_TOOLS: str = ""
|
||||
|
||||
# === PydanticDeep Configuration ===
|
||||
# Backend type: "state" (in-memory) or "daytona" (Daytona cloud workspace)
|
||||
PYDANTIC_DEEP_BACKEND_TYPE: str = "state"
|
||||
# Feature flags
|
||||
PYDANTIC_DEEP_INCLUDE_SUBAGENTS: bool = True # subagent delegation
|
||||
PYDANTIC_DEEP_INCLUDE_SKILLS: bool = True # SKILL.md discovery
|
||||
PYDANTIC_DEEP_INCLUDE_PLAN: bool = True # planner subagent
|
||||
PYDANTIC_DEEP_INCLUDE_MEMORY: bool = True # MEMORY.md persistence
|
||||
PYDANTIC_DEEP_INCLUDE_EXECUTE: bool = False # shell execution (security risk — off by default)
|
||||
PYDANTIC_DEEP_WEB_SEARCH: bool = True # built-in pydantic-ai web search
|
||||
|
||||
# === RAG (Retrieval Augmented Generation) ===
|
||||
# Vector Database (Qdrant)
|
||||
QDRANT_HOST: str = "localhost"
|
||||
QDRANT_PORT: int = 6333
|
||||
QDRANT_API_KEY: str = ""
|
||||
|
||||
# Embeddings
|
||||
EMBEDDING_MODEL: str = "text-embedding-3-small"
|
||||
|
||||
# Chunking
|
||||
RAG_CHUNK_SIZE: int = 512
|
||||
RAG_CHUNK_OVERLAP: int = 50
|
||||
|
||||
# Retrieval
|
||||
RAG_DEFAULT_COLLECTION: str = "documents"
|
||||
RAG_TOP_K: int = 10
|
||||
RAG_CHUNKING_STRATEGY: str = "recursive" # recursive, markdown, or fixed
|
||||
RAG_HYBRID_SEARCH: bool = False # Enable BM25 + vector hybrid search
|
||||
RAG_ENABLE_OCR: bool = False # OCR fallback for scanned PDFs (requires tesseract)
|
||||
|
||||
# Reranker
|
||||
HF_TOKEN: str = ""
|
||||
CROSS_ENCODER_MODEL: str = "cross-encoder/ms-marco-MiniLM-L6-v2"
|
||||
|
||||
# Document Parser
|
||||
|
||||
# Google Drive (optional, for document ingestion via service account)
|
||||
|
||||
# S3 (optional, for document ingestion from S3/MinIO)
|
||||
|
||||
# === CORS ===
|
||||
CORS_ORIGINS: list[str] = [
|
||||
"http://localhost:3000",
|
||||
"http://localhost:8080",
|
||||
]
|
||||
CORS_ALLOW_CREDENTIALS: bool = True
|
||||
CORS_ALLOW_METHODS: list[str] = ["*"]
|
||||
CORS_ALLOW_HEADERS: list[str] = ["*"]
|
||||
|
||||
@field_validator("CORS_ORIGINS")
|
||||
@classmethod
|
||||
def validate_cors_origins(cls, v: list[str], info: ValidationInfo) -> list[str]:
|
||||
"""Warn if CORS_ORIGINS is too permissive in production."""
|
||||
env = info.data.get("ENVIRONMENT", "local") if info.data else "local"
|
||||
if "*" in v and env == "production":
|
||||
raise ValueError(
|
||||
"CORS_ORIGINS cannot contain '*' in production! Specify explicit allowed origins."
|
||||
)
|
||||
return v
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def rag(self) -> "RAGSettings":
|
||||
"""Build RAG-specific settings."""
|
||||
from app.services.rag.config import RAGSettings, DocumentParser, PdfParser, EmbeddingsConfig
|
||||
|
||||
pdf_parser = PdfParser()
|
||||
|
||||
return RAGSettings(
|
||||
collection_name=self.RAG_DEFAULT_COLLECTION,
|
||||
chunk_size=self.RAG_CHUNK_SIZE,
|
||||
chunk_overlap=self.RAG_CHUNK_OVERLAP,
|
||||
chunking_strategy=self.RAG_CHUNKING_STRATEGY,
|
||||
enable_hybrid_search=self.RAG_HYBRID_SEARCH,
|
||||
enable_ocr=self.RAG_ENABLE_OCR,
|
||||
embeddings_config=EmbeddingsConfig(model=self.EMBEDDING_MODEL),
|
||||
document_parser=DocumentParser(),
|
||||
pdf_parser=pdf_parser,
|
||||
)
|
||||
|
||||
|
||||
# Rebuild Settings to resolve RAGSettings forward reference
|
||||
from app.services.rag.config import RAGSettings
|
||||
|
||||
Settings.model_rebuild()
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,148 @@
|
||||
"""CSRF protection middleware for FastAPI.
|
||||
|
||||
This module provides CSRF (Cross-Site Request Forgery) protection for
|
||||
state-changing HTTP methods (POST, PUT, PATCH, DELETE).
|
||||
|
||||
The protection works by:
|
||||
1. Setting a CSRF token in a cookie on initial request
|
||||
2. Requiring the token to be sent in a header for state-changing requests
|
||||
3. Comparing the cookie token with the header token
|
||||
|
||||
Usage:
|
||||
Add to your main.py:
|
||||
|
||||
from app.core.csrf import CSRFMiddleware
|
||||
|
||||
app.add_middleware(CSRFMiddleware)
|
||||
|
||||
For endpoints that should be exempt (e.g., login):
|
||||
|
||||
@router.post("/login", tags=["csrf-exempt"])
|
||||
async def login(...):
|
||||
...
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from fastapi import Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
"""CSRF protection middleware.
|
||||
|
||||
Protects against Cross-Site Request Forgery attacks by requiring
|
||||
a token to be present in both a cookie and a header for state-changing requests.
|
||||
"""
|
||||
|
||||
# Methods that require CSRF protection
|
||||
PROTECTED_METHODS: ClassVar[set[str]] = {"POST", "PUT", "PATCH", "DELETE"}
|
||||
|
||||
# Cookie settings
|
||||
COOKIE_NAME: ClassVar[str] = "csrf_token"
|
||||
HEADER_NAME: ClassVar[str] = "X-CSRF-Token"
|
||||
|
||||
# Paths to exclude from CSRF protection
|
||||
EXEMPT_PATHS: ClassVar[set[str]] = {
|
||||
"/api/v1/auth/login",
|
||||
"/api/v1/auth/register",
|
||||
"/api/v1/auth/refresh",
|
||||
"/api/v1/health",
|
||||
"/api/v1/ready",
|
||||
"/docs",
|
||||
"/openapi.json",
|
||||
"/redoc",
|
||||
}
|
||||
|
||||
def __init__(self, app: ASGIApp, **kwargs: Any) -> None:
|
||||
super().__init__(app)
|
||||
self.exempt_paths: set[str] = set(kwargs.get("exempt_paths", self.EXEMPT_PATHS))
|
||||
self.cookie_name: str = kwargs.get("cookie_name", self.COOKIE_NAME)
|
||||
self.header_name: str = kwargs.get("header_name", self.HEADER_NAME)
|
||||
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
"""Handle the request and apply CSRF protection."""
|
||||
# Skip for exempt paths
|
||||
if self._is_exempt(request):
|
||||
return await call_next(request)
|
||||
|
||||
# Get or generate CSRF token
|
||||
csrf_token = request.cookies.get(self.cookie_name)
|
||||
if not csrf_token:
|
||||
csrf_token = self._generate_token()
|
||||
|
||||
# Check CSRF for protected methods
|
||||
if request.method in self.PROTECTED_METHODS:
|
||||
header_token = request.headers.get(self.header_name)
|
||||
|
||||
if not header_token:
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={
|
||||
"detail": "CSRF token missing",
|
||||
"message": f"Include the '{self.header_name}' header with the CSRF token",
|
||||
},
|
||||
)
|
||||
|
||||
if not secrets.compare_digest(csrf_token, header_token):
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={
|
||||
"detail": "CSRF token invalid",
|
||||
"message": "The CSRF token does not match",
|
||||
},
|
||||
)
|
||||
|
||||
# Process the request
|
||||
response = await call_next(request)
|
||||
|
||||
# Set CSRF token cookie if not present
|
||||
if not request.cookies.get(self.cookie_name):
|
||||
response.set_cookie(
|
||||
key=self.cookie_name,
|
||||
value=csrf_token,
|
||||
httponly=False, # JavaScript needs to read this
|
||||
secure=not settings.DEBUG,
|
||||
samesite="lax",
|
||||
max_age=3600 * 24, # 24 hours
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
def _is_exempt(self, request: Request) -> bool:
|
||||
"""Check if the request path is exempt from CSRF protection."""
|
||||
path = request.url.path
|
||||
|
||||
# Check exact path matches
|
||||
if path in self.exempt_paths:
|
||||
return True
|
||||
|
||||
# Check path prefixes
|
||||
for exempt in self.exempt_paths:
|
||||
if path.startswith(exempt):
|
||||
return True
|
||||
|
||||
# Check if endpoint has "csrf-exempt" tag
|
||||
route = request.scope.get("route")
|
||||
return bool(route and hasattr(route, "tags") and "csrf-exempt" in route.tags)
|
||||
|
||||
@staticmethod
|
||||
def _generate_token() -> str:
|
||||
"""Generate a secure CSRF token."""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def get_csrf_token(request: Request) -> str:
|
||||
"""Get the current CSRF token from cookies or generate a new one.
|
||||
|
||||
Use this in templates or API responses to provide the token to clients.
|
||||
"""
|
||||
token = request.cookies.get(CSRFMiddleware.COOKIE_NAME)
|
||||
if not token:
|
||||
token = secrets.token_urlsafe(32)
|
||||
return token
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Application exceptions.
|
||||
|
||||
Domain exceptions with HTTP status codes for the hybrid approach.
|
||||
These exceptions are caught by exception handlers and converted to proper HTTP responses.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class AppException(Exception):
|
||||
"""Base exception for all application errors.
|
||||
|
||||
Attributes:
|
||||
message: Human-readable error message.
|
||||
code: Machine-readable error code for clients.
|
||||
status_code: HTTP status code to return.
|
||||
details: Additional error details (e.g., field names, IDs).
|
||||
"""
|
||||
|
||||
message: str = "An error occurred"
|
||||
code: str = "APP_ERROR"
|
||||
status_code: int = 500
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str | None = None,
|
||||
code: str | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
):
|
||||
self.message = message or self.__class__.message
|
||||
self.code = code or self.__class__.code
|
||||
self.details = details or {}
|
||||
super().__init__(self.message)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}(message={self.message!r}, code={self.code!r})"
|
||||
|
||||
|
||||
# === 4xx Client Errors ===
|
||||
|
||||
|
||||
class NotFoundError(AppException):
|
||||
"""Resource not found (404)."""
|
||||
|
||||
message = "Resource not found"
|
||||
code = "NOT_FOUND"
|
||||
status_code = 404
|
||||
|
||||
|
||||
class AlreadyExistsError(AppException):
|
||||
"""Resource already exists (409)."""
|
||||
|
||||
message = "Resource already exists"
|
||||
code = "ALREADY_EXISTS"
|
||||
status_code = 409
|
||||
|
||||
|
||||
class ValidationError(AppException):
|
||||
"""Validation error (422)."""
|
||||
|
||||
message = "Validation error"
|
||||
code = "VALIDATION_ERROR"
|
||||
status_code = 422
|
||||
|
||||
|
||||
class AuthenticationError(AppException):
|
||||
"""Authentication failed (401)."""
|
||||
|
||||
message = "Authentication failed"
|
||||
code = "AUTHENTICATION_ERROR"
|
||||
status_code = 401
|
||||
|
||||
|
||||
class AuthorizationError(AppException):
|
||||
"""Authorization failed - insufficient permissions (403)."""
|
||||
|
||||
message = "Insufficient permissions"
|
||||
code = "AUTHORIZATION_ERROR"
|
||||
status_code = 403
|
||||
|
||||
|
||||
class RateLimitError(AppException):
|
||||
"""Rate limit exceeded (429)."""
|
||||
|
||||
message = "Rate limit exceeded"
|
||||
code = "RATE_LIMIT_EXCEEDED"
|
||||
status_code = 429
|
||||
|
||||
|
||||
class BadRequestError(AppException):
|
||||
"""Bad request (400)."""
|
||||
|
||||
message = "Bad request"
|
||||
code = "BAD_REQUEST"
|
||||
status_code = 400
|
||||
|
||||
|
||||
class PaymentRequiredError(AppException):
|
||||
"""Payment required — seat or usage limit reached (402)."""
|
||||
|
||||
message = "Payment required"
|
||||
code = "PAYMENT_REQUIRED"
|
||||
status_code = 402
|
||||
|
||||
|
||||
# === 5xx Server Errors ===
|
||||
|
||||
|
||||
class ExternalServiceError(AppException):
|
||||
"""External service unavailable (503)."""
|
||||
|
||||
message = "External service unavailable"
|
||||
code = "EXTERNAL_SERVICE_ERROR"
|
||||
status_code = 503
|
||||
|
||||
|
||||
class DatabaseError(AppException):
|
||||
"""Database error (500)."""
|
||||
|
||||
message = "Database error"
|
||||
code = "DATABASE_ERROR"
|
||||
status_code = 500
|
||||
|
||||
|
||||
class InternalError(AppException):
|
||||
"""Internal server error (500)."""
|
||||
|
||||
message = "Internal server error"
|
||||
code = "INTERNAL_ERROR"
|
||||
status_code = 500
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Logfire observability configuration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import logfire
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def setup_logfire() -> None:
|
||||
"""Configure Logfire instrumentation."""
|
||||
logfire.configure(
|
||||
token=settings.LOGFIRE_TOKEN,
|
||||
service_name=settings.LOGFIRE_SERVICE_NAME,
|
||||
environment=settings.LOGFIRE_ENVIRONMENT,
|
||||
send_to_logfire="if-token-present",
|
||||
)
|
||||
|
||||
|
||||
def instrument_app(app: Any) -> None:
|
||||
"""Instrument FastAPI app with Logfire."""
|
||||
logfire.instrument_fastapi(app)
|
||||
|
||||
|
||||
def instrument_asyncpg() -> None:
|
||||
"""Instrument asyncpg for PostgreSQL."""
|
||||
logfire.instrument_asyncpg()
|
||||
|
||||
|
||||
def instrument_redis() -> None:
|
||||
"""Instrument Redis."""
|
||||
logfire.instrument_redis()
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Logging utilities — PII redaction filter for GDPR/compliance safety."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import ClassVar
|
||||
|
||||
|
||||
class PiiRedactionFilter(logging.Filter):
|
||||
"""Logging filter that redacts personally identifiable information.
|
||||
|
||||
Automatically scrubs email addresses, JWT tokens, API keys, bearer tokens,
|
||||
and password-like values from log messages to prevent PII leaks to
|
||||
log aggregators (Datadog, CloudWatch, Logfire, etc.).
|
||||
|
||||
Usage:
|
||||
logging.getLogger().addFilter(PiiRedactionFilter())
|
||||
"""
|
||||
|
||||
PATTERNS: ClassVar[list[tuple[re.Pattern[str], str]]] = [
|
||||
# Email addresses
|
||||
(re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"), "[EMAIL_REDACTED]"),
|
||||
# JWT tokens (header.payload.signature)
|
||||
(
|
||||
re.compile(r"eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+"),
|
||||
"[JWT_REDACTED]",
|
||||
),
|
||||
# OpenAI API keys
|
||||
(re.compile(r"sk-[a-zA-Z0-9]{20,}"), "[API_KEY_REDACTED]"),
|
||||
# Anthropic API keys
|
||||
(re.compile(r"sk-ant-[a-zA-Z0-9_-]{20,}"), "[API_KEY_REDACTED]"),
|
||||
# Generic long hex/base64 secrets (40+ chars, likely tokens)
|
||||
(
|
||||
re.compile(
|
||||
r"(?:token|key|secret|password|authorization)[=: ]+['\"]?([A-Za-z0-9_/+=.-]{40,})",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
"[SECRET_REDACTED]",
|
||||
),
|
||||
# Bearer tokens in headers
|
||||
(re.compile(r"Bearer\s+[A-Za-z0-9._~+/=-]{10,}"), "Bearer [TOKEN_REDACTED]"),
|
||||
# Password/secret in key=value or key: value patterns
|
||||
(
|
||||
re.compile(
|
||||
r"(password|passwd|pwd|secret_key|api_key|apikey|auth_token|access_token|refresh_token)"
|
||||
r"[\s]*[=:]\s*['\"]?\S+['\"]?",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
r"\1=[REDACTED]",
|
||||
),
|
||||
]
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
"""Redact PII from log record message and args."""
|
||||
if isinstance(record.msg, str):
|
||||
record.msg = self._redact(record.msg)
|
||||
if record.args:
|
||||
if isinstance(record.args, dict):
|
||||
record.args = {
|
||||
k: self._redact(v) if isinstance(v, str) else v for k, v in record.args.items()
|
||||
}
|
||||
elif isinstance(record.args, tuple):
|
||||
record.args = tuple(
|
||||
self._redact(a) if isinstance(a, str) else a for a in record.args
|
||||
)
|
||||
return True
|
||||
|
||||
def _redact(self, value: str) -> str:
|
||||
for pattern, replacement in self.PATTERNS:
|
||||
value = pattern.sub(replacement, value)
|
||||
return value
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
"""Configure root logger with PII redaction filter."""
|
||||
root_logger = logging.getLogger()
|
||||
# Avoid adding duplicate filters
|
||||
if not any(isinstance(f, PiiRedactionFilter) for f in root_logger.filters):
|
||||
root_logger.addFilter(PiiRedactionFilter())
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Application middleware."""
|
||||
|
||||
from typing import ClassVar
|
||||
from uuid import uuid4
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
|
||||
class RequestIDMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware that adds a unique request ID to each request.
|
||||
|
||||
The request ID is taken from the X-Request-ID header if present,
|
||||
otherwise a new UUID is generated. The ID is added to the response
|
||||
headers and is available in request.state.request_id.
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
"""Add request ID to request state and response headers."""
|
||||
request_id = request.headers.get("X-Request-ID", str(uuid4()))
|
||||
request.state.request_id = request_id
|
||||
|
||||
response = await call_next(request)
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
return response
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware that adds security headers to all responses.
|
||||
|
||||
This includes:
|
||||
- Content-Security-Policy (CSP)
|
||||
- X-Content-Type-Options
|
||||
- X-Frame-Options
|
||||
- X-XSS-Protection
|
||||
- Referrer-Policy
|
||||
- Permissions-Policy
|
||||
|
||||
Usage:
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
|
||||
# Or with custom CSP:
|
||||
app.add_middleware(
|
||||
SecurityHeadersMiddleware,
|
||||
csp_directives={
|
||||
"default-src": "'self'",
|
||||
"script-src": "'self' 'unsafe-inline'",
|
||||
}
|
||||
)
|
||||
"""
|
||||
|
||||
DEFAULT_CSP_DIRECTIVES: ClassVar[dict[str, str]] = {
|
||||
"default-src": "'self'",
|
||||
"script-src": "'self'",
|
||||
"style-src": "'self' 'unsafe-inline'", # Allow inline styles for some UI libs
|
||||
"img-src": "'self' data: https:",
|
||||
"font-src": "'self' data:",
|
||||
"connect-src": "'self'",
|
||||
"frame-ancestors": "'none'",
|
||||
"base-uri": "'self'",
|
||||
"form-action": "'self'",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app: ASGIApp,
|
||||
csp_directives: dict[str, str] | None = None,
|
||||
exclude_paths: set[str] | None = None,
|
||||
) -> None:
|
||||
super().__init__(app)
|
||||
self.csp_directives = csp_directives or self.DEFAULT_CSP_DIRECTIVES
|
||||
self.exclude_paths = exclude_paths or {"/docs", "/redoc", "/openapi.json"}
|
||||
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
"""Add security headers to the response."""
|
||||
response = await call_next(request)
|
||||
|
||||
# Skip for docs/openapi endpoints which need different CSP
|
||||
if request.url.path in self.exclude_paths:
|
||||
return response
|
||||
|
||||
# Build CSP header
|
||||
csp_value = "; ".join(
|
||||
f"{directive} {value}" for directive, value in self.csp_directives.items()
|
||||
)
|
||||
|
||||
# Add security headers — respect any already set by the route, so an
|
||||
# endpoint can opt into less restrictive framing (e.g. user-content
|
||||
# files served inline for the chat preview panel).
|
||||
response.headers.setdefault("Content-Security-Policy", csp_value)
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
response.headers.setdefault("X-XSS-Protection", "1; mode=block")
|
||||
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
response.headers["Permissions-Policy"] = (
|
||||
"accelerometer=(), camera=(), geolocation=(), gyroscope=(), "
|
||||
"magnetometer=(), microphone=(), payment=(), usb=()"
|
||||
)
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Rate limiting configuration using slowapi.
|
||||
|
||||
Default rate limit: 100 requests per 60 seconds.
|
||||
Override with RATE_LIMIT_REQUESTS and RATE_LIMIT_PERIOD environment variables.
|
||||
"""
|
||||
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def get_default_rate_limit() -> str:
|
||||
"""Get default rate limit string from settings.
|
||||
|
||||
Returns a rate limit string like "100/minute" or "60/second".
|
||||
"""
|
||||
requests = settings.RATE_LIMIT_REQUESTS
|
||||
period = settings.RATE_LIMIT_PERIOD
|
||||
|
||||
# Convert period to a human-readable format
|
||||
period_map = {
|
||||
60: "minute",
|
||||
3600: "hour",
|
||||
86400: "day",
|
||||
}
|
||||
|
||||
if period in period_map:
|
||||
return f"{requests}/{period_map[period]}"
|
||||
# For custom periods, use "per X seconds"
|
||||
return f"{requests}/{period} seconds"
|
||||
|
||||
|
||||
# Rate limiter instance with configurable default
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address,
|
||||
default_limits=[get_default_rate_limit()],
|
||||
)
|
||||
|
||||
|
||||
# Common rate limit decorators for convenience
|
||||
# Usage: @rate_limit_low, @rate_limit_medium, @rate_limit_high
|
||||
def rate_limit_low(limit: str = "10/minute"):
|
||||
"""Low rate limit for expensive operations."""
|
||||
return limiter.limit(limit)
|
||||
|
||||
|
||||
def rate_limit_medium(limit: str = "30/minute"):
|
||||
"""Medium rate limit for standard operations."""
|
||||
return limiter.limit(limit)
|
||||
|
||||
|
||||
def rate_limit_high(limit: str = "100/minute"):
|
||||
"""High rate limit for lightweight operations."""
|
||||
return limiter.limit(limit)
|
||||
@@ -0,0 +1,433 @@
|
||||
"""Input sanitization utilities.
|
||||
|
||||
This module provides security-focused input sanitization functions:
|
||||
- HTML sanitization to prevent XSS attacks
|
||||
- Path traversal prevention for file operations
|
||||
- Webhook URL validation to prevent SSRF attacks
|
||||
- Common input cleaning utilities
|
||||
|
||||
Note: SQL injection is prevented by using SQLAlchemy ORM with parameterized queries.
|
||||
"""
|
||||
|
||||
import html
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
from typing import TypeVar
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Default allowed HTML tags for rich text content
|
||||
DEFAULT_ALLOWED_TAGS = frozenset(
|
||||
{
|
||||
"a",
|
||||
"abbr",
|
||||
"acronym",
|
||||
"b",
|
||||
"blockquote",
|
||||
"br",
|
||||
"code",
|
||||
"em",
|
||||
"i",
|
||||
"li",
|
||||
"ol",
|
||||
"p",
|
||||
"pre",
|
||||
"strong",
|
||||
"ul",
|
||||
}
|
||||
)
|
||||
|
||||
# Default allowed HTML attributes
|
||||
DEFAULT_ALLOWED_ATTRIBUTES = {
|
||||
"a": frozenset({"href", "title", "rel"}),
|
||||
"abbr": frozenset({"title"}),
|
||||
"acronym": frozenset({"title"}),
|
||||
}
|
||||
|
||||
# Allowed URL schemes for webhook URLs
|
||||
WEBHOOK_ALLOWED_SCHEMES = frozenset({"http", "https"})
|
||||
|
||||
# Shared Address Space (RFC 6598) — CGNAT range.
|
||||
# Python 3.11+ no longer classifies 100.64.0.0/10 as private or reserved,
|
||||
# so we block it explicitly. Covers cloud metadata endpoints like
|
||||
# Alibaba Cloud's 100.100.100.200.
|
||||
_CGNAT_NETWORK = ipaddress.ip_network("100.64.0.0/10")
|
||||
|
||||
|
||||
class SSRFBlockedError(ValueError):
|
||||
"""Raised when a URL is blocked by SSRF protection.
|
||||
|
||||
Dedicated exception type to avoid fragile string matching when
|
||||
distinguishing SSRF blocks from other ValueErrors.
|
||||
"""
|
||||
|
||||
|
||||
def sanitize_html(
|
||||
content: str,
|
||||
allowed_tags: frozenset[str] | None = None,
|
||||
strip: bool = True,
|
||||
) -> str:
|
||||
"""Sanitize HTML content to prevent XSS attacks.
|
||||
|
||||
This is a simple implementation that escapes all HTML.
|
||||
For rich text support, consider using the `bleach` library.
|
||||
|
||||
Args:
|
||||
content: The HTML content to sanitize.
|
||||
allowed_tags: Not used in simple mode (for bleach compatibility).
|
||||
strip: Not used in simple mode (for bleach compatibility).
|
||||
|
||||
Returns:
|
||||
Escaped HTML-safe string.
|
||||
|
||||
Example:
|
||||
>>> sanitize_html("<script>alert('xss')</script>")
|
||||
"<script>alert('xss')</script>"
|
||||
"""
|
||||
if not content:
|
||||
return ""
|
||||
|
||||
return html.escape(content)
|
||||
|
||||
|
||||
def sanitize_filename(filename: str, allow_unicode: bool = False) -> str:
|
||||
"""Sanitize a filename to prevent path traversal and unsafe characters.
|
||||
|
||||
Args:
|
||||
filename: The filename to sanitize.
|
||||
allow_unicode: Whether to allow unicode characters.
|
||||
|
||||
Returns:
|
||||
A safe filename string.
|
||||
|
||||
Example:
|
||||
>>> sanitize_filename("../../../etc/passwd")
|
||||
"etc_passwd"
|
||||
>>> sanitize_filename("hello world.txt")
|
||||
"hello_world.txt"
|
||||
"""
|
||||
if not filename:
|
||||
return ""
|
||||
|
||||
# Normalize unicode
|
||||
if allow_unicode:
|
||||
filename = unicodedata.normalize("NFKC", filename)
|
||||
else:
|
||||
filename = unicodedata.normalize("NFKD", filename).encode("ascii", "ignore").decode("ascii")
|
||||
|
||||
# Get just the filename (remove any path components)
|
||||
filename = os.path.basename(filename)
|
||||
|
||||
# Remove null bytes
|
||||
filename = filename.replace("\x00", "")
|
||||
|
||||
# Replace path separators and special characters
|
||||
filename = re.sub(r"[/\\:*?\"<>|]", "_", filename)
|
||||
|
||||
# Replace multiple underscores/spaces with single underscore
|
||||
filename = re.sub(r"[\s_]+", "_", filename)
|
||||
|
||||
# Remove leading/trailing underscores and dots
|
||||
filename = filename.strip("._")
|
||||
|
||||
# Ensure we have a valid filename
|
||||
if not filename:
|
||||
return "unnamed"
|
||||
|
||||
return filename
|
||||
|
||||
|
||||
def validate_safe_path(
|
||||
base_dir: Path | str,
|
||||
user_path: str,
|
||||
) -> Path:
|
||||
"""Validate that a user-provided path is within the allowed base directory.
|
||||
|
||||
Prevents path traversal attacks by ensuring the resolved path
|
||||
is within the expected directory.
|
||||
|
||||
Args:
|
||||
base_dir: The base directory that all paths must be within.
|
||||
user_path: The user-provided path to validate.
|
||||
|
||||
Returns:
|
||||
The resolved, safe path.
|
||||
|
||||
Raises:
|
||||
ValueError: If the path would escape the base directory.
|
||||
|
||||
Example:
|
||||
>>> validate_safe_path("/uploads", "../../../etc/passwd")
|
||||
Raises ValueError
|
||||
>>> validate_safe_path("/uploads", "images/photo.jpg")
|
||||
Path("/uploads/images/photo.jpg")
|
||||
"""
|
||||
base_path = Path(base_dir).resolve()
|
||||
user_path_sanitized = sanitize_filename(user_path.lstrip("/\\"))
|
||||
|
||||
# Resolve the full path
|
||||
full_path = (base_path / user_path_sanitized).resolve()
|
||||
|
||||
# Check if the resolved path is within the base directory
|
||||
try:
|
||||
full_path.relative_to(base_path)
|
||||
except ValueError as err:
|
||||
raise ValueError(
|
||||
f"Path traversal detected: {user_path!r} would escape {base_dir!r}"
|
||||
) from err
|
||||
|
||||
return full_path
|
||||
|
||||
|
||||
def _is_ip_blocked(ip_str: str) -> bool:
|
||||
"""Check if an IP address is private, reserved, loopback, or link-local.
|
||||
|
||||
Args:
|
||||
ip_str: The IP address string to check.
|
||||
|
||||
Returns:
|
||||
True if the address should be blocked, False if it's safe.
|
||||
"""
|
||||
try:
|
||||
addr = ipaddress.ip_address(ip_str)
|
||||
except ValueError:
|
||||
# If we can't parse it, block it to be safe
|
||||
return True
|
||||
|
||||
return (
|
||||
addr.is_private
|
||||
or addr.is_reserved
|
||||
or addr.is_loopback
|
||||
or addr.is_link_local
|
||||
or addr.is_multicast
|
||||
or addr.is_unspecified
|
||||
or addr in _CGNAT_NETWORK
|
||||
)
|
||||
|
||||
|
||||
def validate_webhook_url(
|
||||
url: str,
|
||||
allowed_schemes: frozenset[str] | None = None,
|
||||
) -> str:
|
||||
"""Validate a webhook URL to prevent SSRF attacks.
|
||||
|
||||
Checks that the URL:
|
||||
- Uses an allowed scheme (http/https only by default)
|
||||
- Does not contain userinfo (credentials in the URL)
|
||||
- Does not point to private, reserved, loopback, or link-local IP addresses
|
||||
- Resolves via DNS to a public IP (prevents DNS rebinding attacks)
|
||||
|
||||
Args:
|
||||
url: The webhook URL to validate.
|
||||
allowed_schemes: Allowed URL schemes. Defaults to {"http", "https"}.
|
||||
|
||||
Returns:
|
||||
The validated URL string.
|
||||
|
||||
Raises:
|
||||
SSRFBlockedError: If the URL is blocked by SSRF protection.
|
||||
ValueError: If the URL is malformed.
|
||||
|
||||
Example:
|
||||
>>> validate_webhook_url("https://example.com/webhook")
|
||||
"https://example.com/webhook"
|
||||
>>> validate_webhook_url("http://169.254.169.254/latest/meta-data/")
|
||||
Raises SSRFBlockedError
|
||||
"""
|
||||
if allowed_schemes is None:
|
||||
allowed_schemes = WEBHOOK_ALLOWED_SCHEMES
|
||||
|
||||
# Parse the URL
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except Exception as err:
|
||||
raise ValueError(f"Invalid webhook URL: {url!r}") from err
|
||||
|
||||
# Validate scheme
|
||||
if parsed.scheme not in allowed_schemes:
|
||||
raise SSRFBlockedError(
|
||||
f"URL scheme {parsed.scheme!r} is not allowed. "
|
||||
f"Allowed schemes: {', '.join(sorted(allowed_schemes))}"
|
||||
)
|
||||
|
||||
# Extract hostname
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
raise ValueError(f"Invalid webhook URL: no hostname found in {url!r}")
|
||||
|
||||
# Reject URLs with userinfo (credentials) to prevent URL parsing ambiguities
|
||||
# e.g. http://user:pass@host/ or http://foo@169.254.169.254%00@public.com/
|
||||
if parsed.username is not None or parsed.password is not None:
|
||||
raise SSRFBlockedError(
|
||||
"Webhook URL must not contain credentials (userinfo). "
|
||||
"Remove the user:password@ portion from the URL."
|
||||
)
|
||||
|
||||
# Try to parse hostname directly as an IP address
|
||||
try:
|
||||
addr = ipaddress.ip_address(hostname)
|
||||
if _is_ip_blocked(str(addr)):
|
||||
raise SSRFBlockedError(
|
||||
f"Webhook URL blocked: {hostname!r} resolves to a private/internal "
|
||||
f"address. SSRF protection does not allow requests to internal networks."
|
||||
)
|
||||
return url
|
||||
except SSRFBlockedError:
|
||||
raise
|
||||
except ValueError:
|
||||
# Not an IP literal — continue to DNS resolution below
|
||||
pass
|
||||
|
||||
# Determine the correct default port based on the scheme
|
||||
default_port = 443 if parsed.scheme == "https" else 80
|
||||
port = parsed.port or default_port
|
||||
|
||||
# Resolve hostname via DNS and check all returned addresses
|
||||
# TODO: socket.getaddrinfo() is blocking I/O — in async code paths
|
||||
# (PostgreSQL, MongoDB) consider using loop.getaddrinfo() or run_in_executor.
|
||||
try:
|
||||
addr_infos = socket.getaddrinfo(hostname, port, proto=socket.IPPROTO_TCP)
|
||||
except socket.gaierror as err:
|
||||
raise SSRFBlockedError(
|
||||
f"Webhook URL blocked: unable to resolve hostname {hostname!r}"
|
||||
) from err
|
||||
|
||||
if not addr_infos:
|
||||
raise SSRFBlockedError(
|
||||
f"Webhook URL blocked: hostname {hostname!r} did not resolve to any address"
|
||||
)
|
||||
|
||||
for _family, _type, _proto, _canonname, sockaddr in addr_infos:
|
||||
ip_str = str(sockaddr[0])
|
||||
if _is_ip_blocked(ip_str):
|
||||
raise SSRFBlockedError(
|
||||
f"Webhook URL blocked: {hostname!r} resolves to private/internal "
|
||||
f"address {ip_str!r}. SSRF protection does not allow requests to "
|
||||
f"internal networks."
|
||||
)
|
||||
|
||||
return url
|
||||
|
||||
|
||||
def sanitize_string(
|
||||
value: str,
|
||||
max_length: int | None = None,
|
||||
allow_newlines: bool = True,
|
||||
strip_whitespace: bool = True,
|
||||
) -> str:
|
||||
"""Sanitize a string input with various options.
|
||||
|
||||
Args:
|
||||
value: The string to sanitize.
|
||||
max_length: Maximum allowed length (truncates if exceeded).
|
||||
allow_newlines: Whether to preserve newlines.
|
||||
strip_whitespace: Whether to strip leading/trailing whitespace.
|
||||
|
||||
Returns:
|
||||
Sanitized string.
|
||||
"""
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
# Strip null bytes and other control characters (except newlines if allowed)
|
||||
if allow_newlines:
|
||||
value = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", value)
|
||||
else:
|
||||
value = re.sub(r"[\x00-\x1f\x7f]", "", value)
|
||||
|
||||
# Strip whitespace if requested
|
||||
if strip_whitespace:
|
||||
value = value.strip()
|
||||
|
||||
# Truncate if needed
|
||||
if max_length is not None and len(value) > max_length:
|
||||
value = value[:max_length]
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def sanitize_email(email: str) -> str:
|
||||
"""Basic email sanitization.
|
||||
|
||||
Note: For proper email validation, use Pydantic's EmailStr type.
|
||||
This function only performs basic cleaning.
|
||||
|
||||
Args:
|
||||
email: The email address to sanitize.
|
||||
|
||||
Returns:
|
||||
Lowercased, stripped email.
|
||||
"""
|
||||
if not email:
|
||||
return ""
|
||||
|
||||
return email.strip().lower()
|
||||
|
||||
|
||||
T = TypeVar("T", int, float)
|
||||
|
||||
|
||||
def sanitize_numeric(
|
||||
value: str | int | float,
|
||||
value_type: type[T],
|
||||
min_value: T | None = None,
|
||||
max_value: T | None = None,
|
||||
default: T | None = None,
|
||||
) -> T | None:
|
||||
"""Sanitize and validate a numeric value.
|
||||
|
||||
Args:
|
||||
value: The value to sanitize (can be string or numeric).
|
||||
value_type: The expected type (int or float).
|
||||
min_value: Minimum allowed value.
|
||||
max_value: Maximum allowed value.
|
||||
default: Default value if conversion fails.
|
||||
|
||||
Returns:
|
||||
The sanitized numeric value, or default if invalid.
|
||||
|
||||
Example:
|
||||
>>> sanitize_numeric("100", int, min_value=0, max_value=1000)
|
||||
100
|
||||
>>> sanitize_numeric("abc", int, default=0)
|
||||
0
|
||||
"""
|
||||
try:
|
||||
result = value_type(value)
|
||||
|
||||
if min_value is not None and result < min_value:
|
||||
result = min_value
|
||||
if max_value is not None and result > max_value:
|
||||
result = max_value
|
||||
|
||||
return result
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def escape_sql_like(pattern: str, escape_char: str = "\\") -> str:
|
||||
"""Escape special characters in a LIKE pattern.
|
||||
|
||||
Use this when building LIKE queries with user input.
|
||||
|
||||
Args:
|
||||
pattern: The pattern to escape.
|
||||
escape_char: The escape character to use.
|
||||
|
||||
Returns:
|
||||
Escaped pattern safe for use in LIKE queries.
|
||||
|
||||
Example:
|
||||
>>> escape_sql_like("100%")
|
||||
"100\\%"
|
||||
>>> escape_sql_like("under_score")
|
||||
"under\\_score"
|
||||
"""
|
||||
# Escape the escape character first, then special chars
|
||||
pattern = pattern.replace(escape_char, escape_char + escape_char)
|
||||
pattern = pattern.replace("%", escape_char + "%")
|
||||
pattern = pattern.replace("_", escape_char + "_")
|
||||
return pattern
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Security utilities for JWT authentication."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import bcrypt
|
||||
import jwt
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def create_access_token(
|
||||
subject: str | Any,
|
||||
expires_delta: timedelta | None = None,
|
||||
) -> str:
|
||||
"""Create a JWT access token."""
|
||||
if expires_delta:
|
||||
expire = datetime.now(UTC) + expires_delta
|
||||
else:
|
||||
expire = datetime.now(UTC) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
to_encode = {"exp": expire, "sub": str(subject), "type": "access"}
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(
|
||||
subject: str | Any,
|
||||
expires_delta: timedelta | None = None,
|
||||
) -> str:
|
||||
"""Create a JWT refresh token."""
|
||||
if expires_delta:
|
||||
expire = datetime.now(UTC) + expires_delta
|
||||
else:
|
||||
expire = datetime.now(UTC) + timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
to_encode = {"exp": expire, "sub": str(subject), "type": "refresh"}
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
|
||||
def verify_token(token: str) -> dict[str, Any] | None:
|
||||
"""Verify a JWT token and return payload."""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.SECRET_KEY,
|
||||
algorithms=[settings.ALGORITHM],
|
||||
)
|
||||
return payload
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
|
||||
|
||||
def create_password_reset_token(
|
||||
subject: str | Any,
|
||||
expires_delta: timedelta | None = None,
|
||||
) -> str:
|
||||
"""Single-use JWT for password reset.
|
||||
|
||||
Short-lived (1h default). The `type` claim distinguishes it from access /
|
||||
refresh / magic-link tokens — a stolen reset token can't be used as an
|
||||
access token.
|
||||
"""
|
||||
expire = datetime.now(UTC) + (expires_delta or timedelta(hours=1))
|
||||
to_encode = {"exp": expire, "sub": str(subject), "type": "password_reset"}
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
|
||||
def create_magic_link_token(
|
||||
subject: str | Any,
|
||||
expires_delta: timedelta | None = None,
|
||||
) -> str:
|
||||
"""Sign-in-by-email JWT. Short-lived (15 min default)."""
|
||||
expire = datetime.now(UTC) + (expires_delta or timedelta(minutes=15))
|
||||
to_encode = {"exp": expire, "sub": str(subject), "type": "magic_link"}
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
|
||||
def verify_special_token(token: str, expected_type: str) -> dict[str, Any] | None:
|
||||
"""Verify a non-access JWT (password_reset, magic_link) and require a
|
||||
specific `type` claim. Returns payload on success, None otherwise.
|
||||
"""
|
||||
payload = verify_token(token)
|
||||
if payload is None:
|
||||
return None
|
||||
if payload.get("type") != expected_type:
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against a hash."""
|
||||
return bcrypt.checkpw(
|
||||
plain_password.encode("utf-8"),
|
||||
hashed_password.encode("utf-8"),
|
||||
)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Hash a password."""
|
||||
return bcrypt.hashpw(
|
||||
password.encode("utf-8"),
|
||||
bcrypt.gensalt(),
|
||||
).decode("utf-8")
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Database module."""
|
||||
|
||||
# SQLModel uses SQLModel class directly as base, no separate Base class needed
|
||||
from app.db.base import TimestampMixin
|
||||
|
||||
__all__ = ["TimestampMixin"]
|
||||
@@ -0,0 +1,33 @@
|
||||
"""SQLModel base model."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, func
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
# Naming convention for database constraints and indexes
|
||||
# This ensures consistent naming across all migrations
|
||||
NAMING_CONVENTION = {
|
||||
"ix": "%(column_0_label)s_idx",
|
||||
"uq": "%(table_name)s_%(column_0_name)s_key",
|
||||
"ck": "%(table_name)s_%(constraint_name)s_check",
|
||||
"fk": "%(table_name)s_%(column_0_name)s_fkey",
|
||||
"pk": "%(table_name)s_pkey",
|
||||
}
|
||||
|
||||
# Apply naming convention to SQLModel metadata
|
||||
SQLModel.metadata.naming_convention = NAMING_CONVENTION
|
||||
|
||||
|
||||
class TimestampMixin(SQLModel):
|
||||
"""Mixin for created_at and updated_at timestamps."""
|
||||
|
||||
created_at: datetime = Field(
|
||||
sa_type=DateTime(timezone=True),
|
||||
sa_column_kwargs={"server_default": func.now(), "nullable": False},
|
||||
)
|
||||
updated_at: datetime | None = Field(
|
||||
default=None,
|
||||
sa_type=DateTime(timezone=True),
|
||||
sa_column_kwargs={"onupdate": func.now(), "nullable": True},
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Database models."""
|
||||
|
||||
# ruff: noqa: I001, RUF022 - Imports structured for Jinja2 template conditionals
|
||||
from app.db.models.user import User
|
||||
from app.db.models.conversation import Conversation, Message, ToolCall
|
||||
from app.db.models.webhook import Webhook, WebhookDelivery
|
||||
from app.db.models.chat_file import ChatFile
|
||||
from app.db.models.message_rating import MessageRating
|
||||
from app.db.models.rag_document import RAGDocument
|
||||
from app.db.models.sync_log import SyncLog
|
||||
from app.db.models.sync_source import SyncSource
|
||||
from app.db.models.conversation_share import ConversationShare
|
||||
from app.db.models.project import Project, ProjectMember
|
||||
from app.db.models.user_slash_command import UserSlashCommand
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"Conversation",
|
||||
"Message",
|
||||
"ToolCall",
|
||||
"Webhook",
|
||||
"WebhookDelivery",
|
||||
"ChatFile",
|
||||
"MessageRating",
|
||||
"RAGDocument",
|
||||
"SyncLog",
|
||||
"SyncSource",
|
||||
"ConversationShare",
|
||||
"Project",
|
||||
"ProjectMember",
|
||||
"UserSlashCommand",
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
"""ChatFile database model - stores metadata for files uploaded in chat."""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Column, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from app.db.base import TimestampMixin
|
||||
|
||||
|
||||
class ChatFile(TimestampMixin, SQLModel, table=True):
|
||||
"""Tracks files uploaded by users in chat conversations."""
|
||||
|
||||
__tablename__ = "chat_files"
|
||||
|
||||
id: uuid.UUID = Field(
|
||||
default_factory=uuid.uuid4,
|
||||
sa_column=Column(PG_UUID(as_uuid=True), primary_key=True),
|
||||
)
|
||||
user_id: uuid.UUID = Field(
|
||||
sa_column=Column(
|
||||
PG_UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
)
|
||||
message_id: uuid.UUID | None = Field(
|
||||
default=None,
|
||||
sa_column=Column(
|
||||
PG_UUID(as_uuid=True), ForeignKey("messages.id", ondelete="CASCADE"), nullable=True
|
||||
),
|
||||
)
|
||||
filename: str = Field(sa_column=Column(String(255), nullable=False))
|
||||
mime_type: str = Field(sa_column=Column(String(100), nullable=False))
|
||||
size: int = Field(sa_column=Column(Integer, nullable=False))
|
||||
storage_path: str = Field(sa_column=Column(String(500), nullable=False))
|
||||
file_type: str = Field(sa_column=Column(String(20), nullable=False))
|
||||
parsed_content: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ChatFile(id={self.id}, filename={self.filename}, type={self.file_type})>"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user