mirror of
https://github.com/furyhawk/agent_alpha.git
synced 2026-07-21 02:25:34 +00:00
feat: Implement session creation timestamps and enhance session management in API
This commit is contained in:
@@ -12,7 +12,7 @@ agent_alpha/
|
||||
│ ├── core/
|
||||
│ │ ├── agent.py # AgentService (lazy init, capabilities)
|
||||
│ │ ├── config.py # pydantic-settings from .env
|
||||
│ │ ├── database.py# SQLAlchemy async engine + Valkey client + migrations
|
||||
│ │ ├── database.py# SQLAlchemy async engine + Valkey client + migrations; session created_at tracking
|
||||
│ │ ├── dependencies.py # FastAPI DI providers
|
||||
│ │ └── models.py # SQLAlchemy ORM models (User)
|
||||
│ └── routes/
|
||||
@@ -78,6 +78,7 @@ make compose-down # Stop everything
|
||||
- **Interfaces** at file top, exported when reused.
|
||||
- **Tailwind utility classes** inline; no CSS modules or styled-components.
|
||||
- **API client** isolated in `api.ts` — exports for chat (`sendMessage`, `getHistory`), auth (`login`, `register`, `getMe`, `logout`), users (`listUsers`, `createUser`, `getUserSessions`), and admin (`getAdminStats`, `adminListUsers`, `adminUpdateUser`, `adminListSessions`, `adminDeleteSession`). Auth token managed via `localStorage` + `Authorization: Bearer` header helpers.
|
||||
- **Session metadata**: Session creation timestamps (`chat:{session_id}:created_at`) are stored in Valkey via `SETNX` on the first message. The `GET /api/users/{user_id}/sessions` endpoint returns `session_id`, `title`, `created_at` (ISO-8601), and `message_count`, sorted newest-first. The frontend displays relative time via `formatSessionTime()` in the sessions sidebar.
|
||||
|
||||
## Key Architecture Decisions
|
||||
|
||||
@@ -104,3 +105,12 @@ Environment variables loaded from `.env` via `pydantic-settings` (`backend/core/
|
||||
7. **Container orchestration**: Backend depends on postgres + valkey being healthy. Use `depends_on` with `condition: service_healthy`. Run with `docker compose up -d` or `podman compose up -d`. The Makefile auto-detects the available runtime.
|
||||
8. **Schema migrations**: `init_db()` in `database.py` runs `Base.metadata.create_all` followed by `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` for new columns. Existing tables are never rebuilt — new columns are added in-place.
|
||||
9. **bcrypt on container restart**: `bcrypt` is a native dependency. If the container fails to start with an import error, ensure `bcrypt` is in `pyproject.toml` and `uv sync` was run during the container build.
|
||||
|
||||
## Key Conventions
|
||||
|
||||
- `db.flush()` in repositories, not `commit()`
|
||||
|
||||
## More Info
|
||||
|
||||
- `docs/architecture.md` - Architecture details
|
||||
- `docs/patterns.md` - Code patterns
|
||||
|
||||
@@ -365,6 +365,7 @@ _MESSAGES_KEY = "chat:{session_id}:messages"
|
||||
_SESSIONS_SET = "chat:sessions"
|
||||
_SESSION_USER_KEY = "chat:{session_id}:user_id"
|
||||
_SESSION_TITLE_KEY = "chat:{session_id}:title"
|
||||
_SESSION_CREATED_KEY = "chat:{session_id}:created_at"
|
||||
_USER_SESSIONS_KEY = "user:{user_id}:sessions"
|
||||
|
||||
|
||||
@@ -382,15 +383,19 @@ async def save_message(
|
||||
if valkey is None:
|
||||
valkey = await get_valkey()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
msg = {
|
||||
"role": role,
|
||||
"content": content,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"timestamp": now.isoformat(),
|
||||
}
|
||||
key = _MESSAGES_KEY.format(session_id=session_id)
|
||||
created_key = _SESSION_CREATED_KEY.format(session_id=session_id)
|
||||
async with valkey.pipeline(transaction=True) as pipe:
|
||||
pipe.rpush(key, json.dumps(msg))
|
||||
pipe.sadd(_SESSIONS_SET, session_id)
|
||||
# Set created_at only for the first message (NX = set if not exists)
|
||||
pipe.setnx(created_key, now.isoformat())
|
||||
if user_id is not None:
|
||||
pipe.set(
|
||||
_SESSION_USER_KEY.format(session_id=session_id),
|
||||
@@ -457,6 +462,18 @@ async def get_session_title(
|
||||
return await valkey.get(key)
|
||||
|
||||
|
||||
async def get_session_created_at(
|
||||
session_id: str,
|
||||
valkey: Redis | None = None,
|
||||
) -> str | None:
|
||||
"""Return the ISO-8601 creation timestamp for a session, or ``None``."""
|
||||
if valkey is None:
|
||||
valkey = await get_valkey()
|
||||
|
||||
key = _SESSION_CREATED_KEY.format(session_id=session_id)
|
||||
return await valkey.get(key)
|
||||
|
||||
|
||||
async def list_sessions(
|
||||
valkey: Redis | None = None,
|
||||
) -> list[str]:
|
||||
@@ -496,10 +513,12 @@ async def delete_session(
|
||||
key = _MESSAGES_KEY.format(session_id=session_id)
|
||||
session_user_key = _SESSION_USER_KEY.format(session_id=session_id)
|
||||
session_title_key = _SESSION_TITLE_KEY.format(session_id=session_id)
|
||||
session_created_key = _SESSION_CREATED_KEY.format(session_id=session_id)
|
||||
async with valkey.pipeline(transaction=True) as pipe:
|
||||
pipe.delete(key)
|
||||
pipe.delete(session_user_key)
|
||||
pipe.delete(session_title_key)
|
||||
pipe.delete(session_created_key)
|
||||
pipe.srem(_SESSIONS_SET, session_id)
|
||||
if user_sessions_key is not None:
|
||||
pipe.srem(user_sessions_key, session_id)
|
||||
|
||||
+19
-1
@@ -14,6 +14,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.core.database import (
|
||||
create_user,
|
||||
get_session_created_at,
|
||||
get_session_messages,
|
||||
get_session_title,
|
||||
get_user,
|
||||
get_user_by_username,
|
||||
@@ -57,6 +59,8 @@ class UserOut(BaseModel):
|
||||
class UserSessionOut(BaseModel):
|
||||
session_id: str
|
||||
title: str | None = None
|
||||
created_at: str | None = None
|
||||
message_count: int = 0
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -150,5 +154,19 @@ async def user_sessions_endpoint(
|
||||
result: list[UserSessionOut] = []
|
||||
for sid in session_ids:
|
||||
title = await get_session_title(sid)
|
||||
result.append(UserSessionOut(session_id=sid, title=title))
|
||||
created_at = await get_session_created_at(sid)
|
||||
messages = await get_session_messages(sid)
|
||||
result.append(
|
||||
UserSessionOut(
|
||||
session_id=sid,
|
||||
title=title,
|
||||
created_at=created_at,
|
||||
message_count=len(messages),
|
||||
)
|
||||
)
|
||||
# Sort newest first by created_at (sessions without timestamp go last)
|
||||
result.sort(
|
||||
key=lambda s: s.created_at or "",
|
||||
reverse=True,
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# Architecture Guide
|
||||
|
||||
The project uses a **hybrid** architecture. Core features (users, auth, chat)
|
||||
are handled with flat function-based calls from routes into `database.py`.
|
||||
RAG features follow a proper **Repository + Service** layered pattern.
|
||||
|
||||
## Request Flows
|
||||
|
||||
### RAG features (layered pattern)
|
||||
|
||||
```
|
||||
HTTP Request → Route → Service → Repository → Database (PostgreSQL)
|
||||
↓
|
||||
Response ← Service ← Repository ←
|
||||
```
|
||||
|
||||
Routes delegate to **services** (business logic), which delegate to
|
||||
**repositories** (data access). Repositories operate on **ORM models**
|
||||
and return domain objects. Responses are serialized through **schemas**.
|
||||
|
||||
```
|
||||
backend/
|
||||
├── db/models/ # SQLAlchemy ORM models
|
||||
├── schemas/ # Pydantic request/response schemas
|
||||
├── repositories/ # Async CRUD helpers (no business logic)
|
||||
├── services/ # Business logic layer
|
||||
└── routes/ # FastAPI endpoints (thin — delegate to services)
|
||||
```
|
||||
|
||||
### Core features (flat pattern — users, auth, chat)
|
||||
|
||||
```
|
||||
HTTP Request → Route → database.py functions → PostgreSQL / Valkey
|
||||
↓
|
||||
Response ←
|
||||
```
|
||||
|
||||
Routes in `routes/{auth,users,chat,admin}.py` call functions directly
|
||||
from `core/database.py`, which contains both PostgreSQL queries and
|
||||
Valkey (Redis-compatible) data access. There are no intermediate
|
||||
service or repository layers for these features.
|
||||
|
||||
```
|
||||
backend/
|
||||
├── core/
|
||||
│ ├── database.py # All DB + Valkey access functions
|
||||
│ ├── models.py # User ORM model only
|
||||
│ └── config.py # pydantic-settings
|
||||
└── routes/
|
||||
├── auth.py # Auth endpoints (login, register, logout)
|
||||
├── users.py # User CRUD + session listing
|
||||
├── chat.py # Chat send + history
|
||||
├── admin.py # Admin dashboard (stats, users, sessions)
|
||||
└── health.py # Health check
|
||||
```
|
||||
|
||||
## Data Stores
|
||||
|
||||
| Data | Store | Access |
|
||||
|------|-------|--------|
|
||||
| Users (auth, profile) | PostgreSQL | `core/database.py` functions + ORM |
|
||||
| Auth tokens | Valkey (key-value) | `core/database.py` functions |
|
||||
| Chat messages & sessions | Valkey (lists, sets) | `core/database.py` functions |
|
||||
| RAG documents, sync logs, chat files | PostgreSQL | Repository → Service → Route |
|
||||
| RAG vector embeddings | Milvus | `services/rag/vectorstore.py` |
|
||||
|
||||
## Session Lifecycle (Valkey)
|
||||
|
||||
Chat sessions are stored entirely in Valkey:
|
||||
|
||||
```
|
||||
chat:{session_id}:messages → List of JSON messages [{role, content, timestamp}]
|
||||
chat:{session_id}:user_id → Owner user ID string
|
||||
chat:{session_id}:title → Short human-readable title
|
||||
chat:{session_id}:created_at → ISO-8601 creation timestamp (SETNX on first msg)
|
||||
chat:sessions → Set of all known session IDs
|
||||
user:{user_id}:sessions → Set of session IDs owned by a user
|
||||
```
|
||||
|
||||
## Key Conventions
|
||||
|
||||
- **`db.flush()`** in repositories, never `commit()` — the outer service/route
|
||||
owns the transaction commit.
|
||||
- **Settings** use `pydantic-settings` (`Settings` class in `config.py`).
|
||||
Field names are snake_case, env vars are UPPER_SNAKE_CASE.
|
||||
- **Async-first** — all I/O uses `async`/`await` (routes, DB, Valkey).
|
||||
- **Error handling** — `HTTPException` in routes; `try/except` with graceful
|
||||
fallback for optional components (e.g., Logfire).
|
||||
@@ -0,0 +1,199 @@
|
||||
# Code Patterns
|
||||
|
||||
## Dependency Injection
|
||||
|
||||
Use FastAPI's `Depends()` for injecting dependencies:
|
||||
|
||||
```python
|
||||
from app.api.deps import get_db, get_current_user
|
||||
|
||||
@router.get("/conversations")
|
||||
async def list_conversations(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
service = ConversationService(db)
|
||||
return await service.get_by_user(current_user.id)
|
||||
```
|
||||
|
||||
> **Important:** Routes never contain direct database calls. All data access
|
||||
> goes through a service, which in turn delegates to a repository.
|
||||
|
||||
Available dependencies in `app/api/deps.py`:
|
||||
- `get_db` - Database session
|
||||
- `get_current_user` - Authenticated user (raises 401 if not authenticated)
|
||||
- `get_current_user_optional` - User or None
|
||||
- `get_redis` - Redis connection
|
||||
|
||||
## Service Layer Pattern
|
||||
|
||||
Every feature uses the same pattern: a service class receives a DB session,
|
||||
instantiates its repository, and provides business-level methods. Services
|
||||
are the **only** layer that raises domain exceptions.
|
||||
|
||||
```python
|
||||
class ConversationService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.repo = ConversationRepository()
|
||||
|
||||
async def create(self, data: ConversationCreate, user_id: UUID) -> Conversation:
|
||||
# Business validation
|
||||
return await self.repo.create(self.db, user_id=user_id, **data.model_dump())
|
||||
|
||||
async def get_or_raise(self, id: UUID) -> Conversation:
|
||||
conv = await self.repo.get_by_id(self.db, id)
|
||||
if not conv:
|
||||
raise NotFoundError(message="Conversation not found", details={"id": str(id)})
|
||||
return conv
|
||||
```
|
||||
|
||||
All current services follow this pattern: `UserService`, `ConversationService`,
|
||||
`FileUploadService`, `FileStorageService`, `RagDocumentService`, `RagSyncService`, `SyncSourceService`.
|
||||
|
||||
## Repository Layer Pattern
|
||||
|
||||
Repositories handle data access only. They contain **no** business logic and
|
||||
always use `flush()` instead of `commit()` so the caller controls transactions:
|
||||
|
||||
```python
|
||||
class ConversationRepository:
|
||||
async def get_by_id(self, db: AsyncSession, id: UUID) -> Conversation | None:
|
||||
return await db.get(Conversation, id)
|
||||
|
||||
async def create(self, db: AsyncSession, **kwargs) -> Conversation:
|
||||
conv = Conversation(**kwargs)
|
||||
db.add(conv)
|
||||
await db.flush() # Not commit! Let dependency manage transaction
|
||||
await db.refresh(conv)
|
||||
return conv
|
||||
|
||||
async def get_by_user(
|
||||
self, db: AsyncSession, user_id: UUID, skip: int = 0, limit: int = 100
|
||||
) -> list[Conversation]:
|
||||
result = await db.execute(
|
||||
select(Conversation)
|
||||
.where(Conversation.user_id == user_id)
|
||||
.offset(skip).limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
```
|
||||
|
||||
## Exception Handling
|
||||
|
||||
Use domain exceptions in services:
|
||||
|
||||
```python
|
||||
from app.core.exceptions import NotFoundError, AlreadyExistsError, ValidationError
|
||||
|
||||
# In service
|
||||
if not conversation:
|
||||
raise NotFoundError(
|
||||
message="Conversation not found",
|
||||
details={"id": str(id)}
|
||||
)
|
||||
|
||||
if await self.repo.exists_by_email(self.db, email):
|
||||
raise AlreadyExistsError(
|
||||
message="User with this email already exists"
|
||||
)
|
||||
```
|
||||
|
||||
Exception handlers convert to HTTP responses automatically.
|
||||
|
||||
## Schema Patterns
|
||||
|
||||
Separate schemas for different operations:
|
||||
|
||||
```python
|
||||
# Base with shared fields
|
||||
class UserBase(BaseModel):
|
||||
email: str
|
||||
full_name: str | None = None
|
||||
|
||||
# For creation (input)
|
||||
class UserCreate(UserBase):
|
||||
password: str
|
||||
|
||||
# For updates (all optional)
|
||||
class UserUpdate(BaseModel):
|
||||
full_name: str | None = None
|
||||
email: str | None = None
|
||||
|
||||
# For responses (with DB fields)
|
||||
class UserResponse(UserBase):
|
||||
id: UUID
|
||||
created_at: datetime
|
||||
updated_at: datetime | None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
```
|
||||
|
||||
## Connector Pattern (RAG Sync)
|
||||
|
||||
Remote document sources (Google Drive, S3, etc.) use a pluggable connector
|
||||
pattern defined in `app/services/rag/connectors/`. Each connector inherits from
|
||||
`BaseSyncConnector` and is registered in the `CONNECTOR_REGISTRY` dictionary.
|
||||
|
||||
### Adding a new connector
|
||||
|
||||
1. Create a file in `app/services/rag/connectors/` (e.g. `sharepoint.py`).
|
||||
2. Subclass `BaseSyncConnector` and implement the required methods.
|
||||
3. Register the connector in `CONNECTOR_REGISTRY`.
|
||||
|
||||
```python
|
||||
from app.services.rag.connectors import BaseSyncConnector, RemoteFile, CONNECTOR_REGISTRY
|
||||
|
||||
class SharePointConnector(BaseSyncConnector):
|
||||
CONNECTOR_TYPE = "sharepoint"
|
||||
DISPLAY_NAME = "SharePoint"
|
||||
CONFIG_SCHEMA = {
|
||||
"site_url": {"label": "Site URL", "required": True},
|
||||
"client_id": {"label": "Client ID", "required": True},
|
||||
}
|
||||
|
||||
async def list_files(self, config: dict) -> list[RemoteFile]:
|
||||
# Return metadata for available files
|
||||
...
|
||||
|
||||
async def download_file(self, file: RemoteFile, dest_dir: Path) -> Path:
|
||||
# Download file to dest_dir, return local Path
|
||||
...
|
||||
|
||||
# Register so the sync service can discover it
|
||||
CONNECTOR_REGISTRY["sharepoint"] = SharePointConnector
|
||||
```
|
||||
|
||||
The `RagSyncService` uses `CONNECTOR_REGISTRY` to look up the right connector
|
||||
by type, validate its config, list remote files, download them, and hand them
|
||||
off to the ingestion pipeline.
|
||||
|
||||
## Frontend Patterns
|
||||
|
||||
### Authentication (HTTP-only cookies)
|
||||
|
||||
```typescript
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
|
||||
function Component() {
|
||||
const { user, isAuthenticated, login, logout } = useAuth();
|
||||
}
|
||||
```
|
||||
|
||||
### State Management (Zustand)
|
||||
|
||||
```typescript
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
const { user, setUser, logout } = useAuthStore();
|
||||
```
|
||||
|
||||
### WebSocket Chat
|
||||
|
||||
```typescript
|
||||
import { useChat } from '@/hooks/use-chat';
|
||||
|
||||
function ChatPage() {
|
||||
const { messages, sendMessage, isStreaming } = useChat();
|
||||
}
|
||||
```
|
||||
+48
-8
@@ -37,6 +37,43 @@ const ROLE_BADGES: Record<string, string> = {
|
||||
viewer: "bg-gray-600",
|
||||
};
|
||||
|
||||
/* ── Date/time helpers ──────────────────────────────────────────── */
|
||||
|
||||
function formatSessionTime(iso: string | null): string {
|
||||
if (!iso) return "";
|
||||
const date = new Date(iso);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
|
||||
// Within the last minute
|
||||
if (diffSec < 60) return "Just now";
|
||||
// Within the last hour
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
// Within the last 24 hours
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
// Yesterday
|
||||
if (diffDay === 1) return "Yesterday";
|
||||
// Within the last 6 days — show day name
|
||||
if (diffDay < 7) {
|
||||
const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
return days[date.getDay()];
|
||||
}
|
||||
// This year — show "Mon D"
|
||||
if (date.getFullYear() === now.getFullYear()) {
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
// Older — show "Mon D, YYYY"
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Auth Page */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -556,7 +593,7 @@ export default function App() {
|
||||
No sessions yet. Start a chat to create one.
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-64 space-y-2 overflow-y-auto">
|
||||
<div className="max-h-80 space-y-2 overflow-y-auto">
|
||||
{userSessions.map((s) => (
|
||||
<button
|
||||
key={s.session_id}
|
||||
@@ -567,19 +604,22 @@ export default function App() {
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-800 text-xs text-gray-400">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gray-800 text-xs text-gray-400">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 truncate">
|
||||
<p className="text-sm text-gray-200">
|
||||
{s.title ?? s.session_id.slice(0, 16) + "…"}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-gray-200">
|
||||
{s.title ?? "Chat " + s.session_id.slice(0, 8) + "…"}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-[11px] text-gray-500">
|
||||
{formatSessionTime(s.created_at)}
|
||||
{s.message_count > 0 && (
|
||||
<> · {s.message_count} {s.message_count === 1 ? "message" : "messages"}</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
{s.session_id.slice(0, 8)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -80,6 +80,8 @@ export interface UserCreateData {
|
||||
export interface UserSessionData {
|
||||
session_id: string;
|
||||
title: string | null;
|
||||
created_at: string | null;
|
||||
message_count: number;
|
||||
}
|
||||
|
||||
/* ── Auth API ──────────────────────────────────────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user