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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ AILINE_REDIS_URL="redis://:ailine_redis_dev@localhost:6311/0"
# === LLM Provider Keys ===
ANTHROPIC_API_KEY=""
OPENAI_API_KEY=""
GEMINI_API_KEY=""
GOOGLE_API_KEY=""
# GEMINI_API_KEY is also accepted as an alias for GOOGLE_API_KEY
OPENROUTER_API_KEY=""

# === Models ===
Expand All @@ -39,7 +40,7 @@ AILINE_LLM_PROVIDER="anthropic"
# === Embedding Provider (gemini|openai|local|openrouter) ===
AILINE_EMBEDDING_PROVIDER="gemini"
AILINE_EMBEDDING_MODEL="gemini-embedding-001"
AILINE_EMBEDDING_DIMENSIONS="3072"
AILINE_EMBEDDING_DIMENSIONS="1536"

# === Vector Store (pgvector|qdrant|chroma) ===
AILINE_VECTORSTORE_PROVIDER="pgvector"
Expand Down Expand Up @@ -69,10 +70,10 @@ HANDTALK_API_KEY=""
AILINE_JWT_SECRET=""

# === Dev Mode (enables X-Teacher-ID header bypass for local dev) ===
AILINE_DEV_MODE="true"
AILINE_DEV_MODE="false"

# === Demo Mode (enables /demo/* endpoints and demo login) ===
AILINE_DEMO_MODE="1"
AILINE_DEMO_MODE="0"

# === Rate Limiting ===
AILINE_RATE_LIMIT_RPM="60"
Expand Down
88 changes: 61 additions & 27 deletions agents/ailine_agents/resilience.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,74 +43,108 @@ def __init__(
self._cooldown_seconds = cooldown_seconds
self._failure_count = 0
self._circuit_open_until: float | None = None
self._state: str = "closed" # closed, open, half_open
self._probe_in_flight: bool = False
self._lock = threading.Lock()

@property
def failure_count(self) -> int:
"""Current consecutive failure count."""
return self._failure_count

@property
def state(self) -> str:
"""Current circuit breaker state: closed, open, or half_open."""
with self._lock:
self._maybe_transition_to_half_open()
return self._state

@property
def is_open(self) -> bool:
"""True if the circuit is open and blocking calls."""
with self._lock:
return self._is_open_locked()
self._maybe_transition_to_half_open()
return self._state == "open"

def _is_open_locked(self) -> bool:
"""Check if circuit is open (must hold _lock)."""
if self._circuit_open_until is None:
return False
# Cooldown expired -- transition to half-open
return time.monotonic() < self._circuit_open_until
def _maybe_transition_to_half_open(self) -> None:
"""Transition from open to half_open if cooldown expired (must hold _lock)."""
if self._state == "open" and self._circuit_open_until is not None:
if time.monotonic() >= self._circuit_open_until:
self._state = "half_open"
self._probe_in_flight = False
log.info("circuit_breaker.half_open", failure_count=self._failure_count)

def check(self) -> bool:
"""Check if a call is allowed.

Returns True if the call can proceed, False if the circuit is open.
"""
with self._lock:
if self._is_open_locked():
log.warning(
"circuit_breaker.blocked",
failure_count=self._failure_count,
open_until=self._circuit_open_until,
remaining_seconds=round(
(self._circuit_open_until or 0) - time.monotonic(), 1
),
)
self._maybe_transition_to_half_open()

if self._state == "closed":
return True

if self._state == "half_open":
if not self._probe_in_flight:
self._probe_in_flight = True
log.info("circuit_breaker.probe_allowed")
return True
log.warning("circuit_breaker.blocked_half_open", msg="Probe already in flight")
return False
return True

# state == "open"
log.warning(
"circuit_breaker.blocked",
failure_count=self._failure_count,
open_until=self._circuit_open_until,
remaining_seconds=round(
(self._circuit_open_until or 0) - time.monotonic(), 1
),
)
return False

def record_success(self) -> None:
"""Record a successful call -- resets the failure counter."""
with self._lock:
prev_state = self._state
prev_count = self._failure_count
self._failure_count = 0
self._circuit_open_until = None
if prev_count > 0:
self._state = "closed"
self._probe_in_flight = False
if prev_count > 0 or prev_state != "closed":
log.info(
"circuit_breaker.reset",
previous_failures=prev_count,
previous_state=prev_state,
)

def record_failure(self) -> None:
"""Record a failed call. Opens the circuit if threshold is reached."""
with self._lock:
self._failure_count += 1

if self._state == "half_open":
# Probe failed -- reopen
self._state = "open"
self._circuit_open_until = time.monotonic() + self._cooldown_seconds
self._probe_in_flight = False
log.error(
"circuit_breaker.probe_failed",
failure_count=self._failure_count,
cooldown_seconds=self._cooldown_seconds,
)
return

if self._failure_count >= self._failure_threshold:
self._state = "open"
self._circuit_open_until = time.monotonic() + self._cooldown_seconds
log.error(
"circuit_breaker.opened",
failure_count=self._failure_count,
cooldown_seconds=self._cooldown_seconds,
open_until=self._circuit_open_until,
)

def reset(self) -> None:
"""Force-reset the circuit breaker (useful for testing)."""
with self._lock:
self._failure_count = 0
self._circuit_open_until = None
self._state = "closed"
self._probe_in_flight = False


class CircuitOpenError(Exception):
Expand Down
99 changes: 77 additions & 22 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,40 @@
# AiLine -- Docker Compose (full dev stack)
# Services: API + Frontend + PostgreSQL 16/pgvector + Redis 7
# Usage: docker compose up -d --build
# AiLine -- Docker Compose (full stack)
# Services: DB (Postgres 16 + pgvector) | Redis 7 | Migrations | API | Frontend
# Usage:
# docker compose up -d --build # Full stack (dev, with override)
# docker compose -f docker-compose.yml up -d --build # Production-like
# docker compose ps # Health status
# docker compose logs -f api # Follow API logs
name: ailine

# ---------------------------------------------------------------------------
# Shared anchors (DRY)
# ---------------------------------------------------------------------------
x-logging-default: &logging-default
driver: json-file
options:
max-size: "10m"
max-file: "3"

x-db-env: &db-env
AILINE_DB_URL: postgresql+asyncpg://${POSTGRES_USER:-ailine}:${POSTGRES_PASSWORD:-ailine_dev}@db:5432/${POSTGRES_DB:-ailine}

x-redis-env: &redis-env
AILINE_REDIS_URL: redis://:${REDIS_PASSWORD:-ailine_redis_dev}@redis:6379/0

x-api-build: &api-build
context: .
dockerfile: runtime/Dockerfile
args:
INSTALL_DEV: "true"

# ---------------------------------------------------------------------------
# Services
# ---------------------------------------------------------------------------
services:

# --- Data Layer -----------------------------------------------------------

db:
image: pgvector/pgvector:0.8.2-pg16
environment:
Expand All @@ -23,11 +54,7 @@ services:
restart: unless-stopped
networks:
- backend
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
logging: *logging-default
deploy:
resources:
limits:
Expand All @@ -37,7 +64,11 @@ services:
image: redis:7.4-alpine
environment:
REDIS_PASSWORD: ${REDIS_PASSWORD:-ailine_redis_dev}
command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru --requirepass ${REDIS_PASSWORD:-ailine_redis_dev}
command: >-
redis-server
--maxmemory 128mb
--maxmemory-policy allkeys-lru
--requirepass ${REDIS_PASSWORD:-ailine_redis_dev}
ports:
- "127.0.0.1:${REDIS_HOST_PORT:-6311}:6379"
healthcheck:
Expand All @@ -58,21 +89,42 @@ services:
limits:
memory: 256M

# --- Migrations (one-shot, runs before API) --------------------------------

migrations:
build: *api-build
command: >-
python -m alembic -c alembic.ini upgrade head
working_dir: /app
environment:
<<: *db-env
env_file:
- path: .env
required: false
depends_on:
db:
condition: service_healthy
restart: "no"
networks:
- backend
logging: *logging-default
deploy:
resources:
limits:
memory: 256M

# --- Backend API -----------------------------------------------------------

api:
build:
context: .
dockerfile: runtime/Dockerfile
args:
INSTALL_DEV: "true"
build: *api-build
ports:
- "${API_HOST_PORT:-8011}:8000"
environment:
AILINE_DB_URL: postgresql+asyncpg://${POSTGRES_USER:-ailine}:${POSTGRES_PASSWORD:-ailine_dev}@db:5432/${POSTGRES_DB:-ailine}
AILINE_REDIS_URL: redis://:${REDIS_PASSWORD:-ailine_redis_dev}@redis:6379/0
<<: [*db-env, *redis-env]
AILINE_LLM_PROVIDER: ${AILINE_LLM_PROVIDER:-anthropic}
AILINE_CORS_ORIGINS: "http://localhost:3011,http://127.0.0.1:3011,http://localhost:3000,http://127.0.0.1:3000,http://frontend:3000"
AILINE_DEV_MODE: "${AILINE_DEV_MODE:-false}"
AILINE_DEMO_MODE: "${AILINE_DEMO_MODE:-1}"
AILINE_DEMO_MODE: "${AILINE_DEMO_MODE:-0}"
AILINE_JWT_SECRET: "${AILINE_JWT_SECRET:-}"
AILINE_LOCAL_STORE: /app/.local_store
env_file:
Expand All @@ -83,6 +135,8 @@ services:
condition: service_healthy
redis:
condition: service_healthy
migrations:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8000/health').raise_for_status()"]
interval: 10s
Expand All @@ -104,6 +158,8 @@ services:
limits:
memory: 1G

# --- Frontend (Next.js 16) ------------------------------------------------

frontend:
build:
context: ./frontend
Expand All @@ -126,16 +182,15 @@ services:
restart: unless-stopped
networks:
- frontend
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
logging: *logging-default
deploy:
resources:
limits:
memory: 2G

# ---------------------------------------------------------------------------
# Volumes & Networks
# ---------------------------------------------------------------------------
volumes:
pgdata:

Expand Down
3 changes: 1 addition & 2 deletions frontend/src/app/[locale]/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { motion, AnimatePresence, useReducedMotion } from 'motion/react'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/cn'
import { useAuthStore, type UserRole } from '@/stores/auth-store'
import { API_BASE, demoLogin, setDemoProfile, getAuthHeaders } from '@/lib/api'
import { API_BASE, demoLogin, setDemoProfile } from '@/lib/api'
import { useAccessibilityStore } from '@/stores/accessibility-store'
import { cssTheme } from '@/hooks/use-theme'
import { DEMO_PROFILES_BY_ROLE, type DemoProfile } from '@/components/auth/login-data'
Expand Down Expand Up @@ -80,7 +80,6 @@ export default function LoginPage() {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders(),
},
body: JSON.stringify({ email, password, role: selectedRole }),
})
Expand Down
19 changes: 15 additions & 4 deletions frontend/src/components/privacy/privacy-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useState, useEffect, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/cn'
import { API_BASE } from '@/lib/api'
import { API_BASE, getAuthHeaders } from '@/lib/api'

interface DataSummary {
plans: number
Expand All @@ -23,9 +23,13 @@ export function PrivacyPanel() {
const [actionState, setActionState] = useState<'idle' | 'exporting' | 'deleting' | 'confirm_delete'>('idle')

useEffect(() => {
const controller = new AbortController()
async function fetchSummary() {
try {
const res = await fetch(`${API_BASE}/api/v1/privacy/data-summary`)
const res = await fetch(`${API_BASE}/privacy/data-summary`, {
headers: getAuthHeaders(),
signal: controller.signal,
})
if (res.ok) {
const data: DataSummary = await res.json()
setSummary(data)
Expand All @@ -37,12 +41,16 @@ export function PrivacyPanel() {
}
}
fetchSummary()
return () => controller.abort()
}, [])

const handleExport = useCallback(async () => {
setActionState('exporting')
try {
await fetch(`${API_BASE}/api/v1/privacy/export`, { method: 'POST' })
await fetch(`${API_BASE}/privacy/export`, {
method: 'POST',
headers: getAuthHeaders(),
})
} catch {
// Demo mode: export simulated
} finally {
Expand All @@ -57,7 +65,10 @@ export function PrivacyPanel() {
}
setActionState('deleting')
try {
await fetch(`${API_BASE}/api/v1/privacy/delete`, { method: 'DELETE' })
await fetch(`${API_BASE}/privacy/delete`, {
method: 'DELETE',
headers: getAuthHeaders(),
})
setSummary({ plans: 0, sessions: 0, materials: 0, last_updated: new Date().toISOString() })
} catch {
// Demo mode: delete simulated
Expand Down
Loading
Loading