initial commit
This commit is contained in:
33
backend/.env.example
Normal file
33
backend/.env.example
Normal file
@@ -0,0 +1,33 @@
|
||||
# FluentGerman.ai — Environment Configuration
|
||||
# Copy to .env and fill in your values
|
||||
|
||||
# App
|
||||
APP_PORT=8999
|
||||
|
||||
# Security
|
||||
SECRET_KEY=generate-a-strong-random-key-here
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
||||
|
||||
# Database (PostgreSQL)
|
||||
DATABASE_URL=postgresql+asyncpg://fluentgerman:YOUR_PASSWORD@localhost:5432/fluentgerman
|
||||
|
||||
# LLM Provider (via LiteLLM — supports openai, anthropic, gemini, etc.)
|
||||
# For Gemini: set LLM_PROVIDER=gemini, LLM_MODEL=gemini-2.0-flash (auto-prefixed)
|
||||
# For OpenAI: set LLM_PROVIDER=openai, LLM_MODEL=gpt-4o-mini
|
||||
LLM_PROVIDER=gemini
|
||||
LLM_API_KEY=your-api-key-here
|
||||
LLM_MODEL=gemini-2.0-flash
|
||||
|
||||
# Voice mode: "api" (OpenAI Whisper/TTS) or "browser" (Web Speech API fallback)
|
||||
VOICE_MODE=browser
|
||||
TTS_MODEL=tts-1
|
||||
TTS_VOICE=alloy
|
||||
STT_MODEL=whisper-1
|
||||
|
||||
# Admin bootstrap (only used on first startup)
|
||||
ADMIN_EMAIL=admin@fluentgerman.ai
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me-immediately
|
||||
|
||||
# Deployment domain (used in nginx template)
|
||||
# APP_DOMAIN=fluentgerman.mydomain.io
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
61
backend/app/auth.py
Normal file
61
backend/app/auth.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""FluentGerman.ai — Authentication & authorization utilities."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import bcrypt
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.database import get_db
|
||||
from app.models import User
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return bcrypt.checkpw(plain.encode(), hashed.encode())
|
||||
|
||||
|
||||
def create_access_token(data: dict) -> str:
|
||||
settings = get_settings()
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
|
||||
to_encode = {**data, "exp": expire}
|
||||
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
credentials_exc = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, get_settings().secret_key, algorithms=[get_settings().algorithm])
|
||||
user_id: int | None = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise credentials_exc
|
||||
except JWTError:
|
||||
raise credentials_exc
|
||||
|
||||
result = await db.execute(select(User).where(User.id == int(user_id)))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None or not user.is_active:
|
||||
raise credentials_exc
|
||||
return user
|
||||
|
||||
|
||||
async def require_admin(user: User = Depends(get_current_user)) -> User:
|
||||
if not user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||
return user
|
||||
45
backend/app/config.py
Normal file
45
backend/app/config.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""FluentGerman.ai — Application configuration."""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""App settings loaded from environment / .env file."""
|
||||
|
||||
# App
|
||||
app_name: str = "FluentGerman.ai"
|
||||
debug: bool = False
|
||||
|
||||
# Security
|
||||
secret_key: str = "CHANGE-ME-in-production"
|
||||
access_token_expire_minutes: int = 60 * 24 # 24h
|
||||
algorithm: str = "HS256"
|
||||
|
||||
# Database
|
||||
database_url: str = "postgresql+asyncpg://fluentgerman:fluentgerman@localhost:5432/fluentgerman"
|
||||
|
||||
# LLM
|
||||
llm_provider: str = "openai" # used by litellm routing
|
||||
llm_api_key: str = ""
|
||||
llm_model: str = "gpt-4o-mini"
|
||||
|
||||
# Voice feature flag: "api" = LLM provider Whisper/TTS, "browser" = Web Speech API
|
||||
voice_mode: Literal["api", "browser"] = "api"
|
||||
tts_model: str = "tts-1"
|
||||
tts_voice: str = "alloy"
|
||||
stt_model: str = "whisper-1"
|
||||
|
||||
# Admin bootstrap
|
||||
admin_email: str = "admin@fluentgerman.ai"
|
||||
admin_username: str = "admin"
|
||||
admin_password: str = "CHANGE-ME"
|
||||
|
||||
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
20
backend/app/database.py
Normal file
20
backend/app/database.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""FluentGerman.ai — Database setup (async PostgreSQL)."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
engine = create_async_engine(get_settings().database_url, echo=get_settings().debug)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
98
backend/app/main.py
Normal file
98
backend/app/main.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""FluentGerman.ai — FastAPI application entry point."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.auth import hash_password
|
||||
from app.config import get_settings
|
||||
from app.database import Base, engine, async_session
|
||||
from app.models import User
|
||||
from app.routers import auth, chat, instructions, users, voice
|
||||
|
||||
# ── Logging ──────────────────────────────────────────────────────────
|
||||
import os
|
||||
os.makedirs("logs", exist_ok=True)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(),
|
||||
logging.FileHandler("logs/app.log", mode="a"),
|
||||
],
|
||||
)
|
||||
logger = logging.getLogger("fluentgerman")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Create tables and bootstrap admin user on startup."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# Bootstrap admin if not exists
|
||||
settings = get_settings()
|
||||
async with async_session() as db:
|
||||
result = await db.execute(select(User).where(User.is_admin == True)) # noqa: E712
|
||||
if not result.scalar_one_or_none():
|
||||
admin = User(
|
||||
username=settings.admin_username,
|
||||
email=settings.admin_email,
|
||||
hashed_password=hash_password(settings.admin_password),
|
||||
is_admin=True,
|
||||
)
|
||||
db.add(admin)
|
||||
await db.commit()
|
||||
logger.info("Admin user created: %s", settings.admin_username)
|
||||
|
||||
logger.info("FluentGerman.ai started — LLM: %s/%s, Voice: %s",
|
||||
settings.llm_provider, settings.llm_model, settings.voice_mode)
|
||||
yield
|
||||
logger.info("FluentGerman.ai shutting down")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="FluentGerman.ai",
|
||||
description="Personalized LLM-powered German language learning platform",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
# ── Request logging middleware ────────────────────────────────────────
|
||||
@app.middleware("http")
|
||||
async def log_requests(request: Request, call_next):
|
||||
start = time.time()
|
||||
response = await call_next(request)
|
||||
duration = round((time.time() - start) * 1000)
|
||||
# Skip logging static file requests to keep logs clean
|
||||
path = request.url.path
|
||||
if path.startswith("/api/"):
|
||||
logger.info("%s %s → %s (%dms)", request.method, path, response.status_code, duration)
|
||||
return response
|
||||
|
||||
|
||||
# CORS — restrict in production
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# API routers
|
||||
app.include_router(auth.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(instructions.router)
|
||||
app.include_router(chat.router)
|
||||
app.include_router(voice.router)
|
||||
|
||||
# Serve frontend static files
|
||||
app.mount("/", StaticFiles(directory="../frontend", html=True), name="frontend")
|
||||
52
backend/app/models.py
Normal file
52
backend/app/models.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""FluentGerman.ai — Database models."""
|
||||
|
||||
import enum
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class InstructionType(str, enum.Enum):
|
||||
GLOBAL = "global"
|
||||
PERSONAL = "personal"
|
||||
HOMEWORK = "homework"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(50), unique=True, index=True)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
hashed_password: Mapped[str] = mapped_column(String(255))
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
instructions: Mapped[list["Instruction"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class Instruction(Base):
|
||||
__tablename__ = "instructions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(200))
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
type: Mapped[InstructionType] = mapped_column(
|
||||
Enum(InstructionType), default=InstructionType.PERSONAL
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
user: Mapped[User | None] = relationship(back_populates="instructions")
|
||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
32
backend/app/routers/auth.py
Normal file
32
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""FluentGerman.ai — Auth router."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import create_access_token, get_current_user, verify_password
|
||||
from app.database import get_db
|
||||
from app.models import User
|
||||
from app.schemas import LoginRequest, Token, UserOut
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.username == body.username))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or not verify_password(body.password, user.hashed_password):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account disabled")
|
||||
|
||||
token = create_access_token({"sub": str(user.id)})
|
||||
return Token(access_token=token)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def me(user: User = Depends(get_current_user)):
|
||||
return user
|
||||
48
backend/app/routers/chat.py
Normal file
48
backend/app/routers/chat.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""FluentGerman.ai — Chat router with SSE streaming."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.database import get_db
|
||||
from app.models import User
|
||||
from app.schemas import ChatRequest
|
||||
from app.services.instruction_service import get_system_prompt
|
||||
from app.services.llm_service import chat_stream
|
||||
|
||||
logger = logging.getLogger("fluentgerman.chat")
|
||||
|
||||
router = APIRouter(prefix="/api/chat", tags=["chat"])
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def chat(
|
||||
body: ChatRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Send a message and receive a streamed SSE response."""
|
||||
logger.info("Chat request from user=%s message_len=%d history=%d",
|
||||
user.username, len(body.message), len(body.history))
|
||||
|
||||
system_prompt = await get_system_prompt(db, user.id)
|
||||
|
||||
messages = [{"role": "system", "content": system_prompt}]
|
||||
for msg in body.history:
|
||||
messages.append({"role": msg.role, "content": msg.content})
|
||||
messages.append({"role": "user", "content": body.message})
|
||||
|
||||
async def event_generator():
|
||||
try:
|
||||
async for token in chat_stream(messages):
|
||||
yield f"data: {json.dumps({'token': token})}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
except Exception as e:
|
||||
logger.error("LLM streaming error: %s", e, exc_info=True)
|
||||
yield f"data: {json.dumps({'error': str(e)})}\n\n"
|
||||
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
68
backend/app/routers/instructions.py
Normal file
68
backend/app/routers/instructions.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""FluentGerman.ai — Instruction management router (admin only)."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_admin
|
||||
from app.database import get_db
|
||||
from app.models import Instruction, InstructionType
|
||||
from app.schemas import InstructionCreate, InstructionOut, InstructionUpdate
|
||||
|
||||
router = APIRouter(prefix="/api/instructions", tags=["instructions"], dependencies=[Depends(require_admin)])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[InstructionOut])
|
||||
async def list_instructions(user_id: int | None = None, db: AsyncSession = Depends(get_db)):
|
||||
query = select(Instruction).order_by(Instruction.created_at.desc())
|
||||
if user_id is not None:
|
||||
# Fetch per-user + global instructions
|
||||
query = query.where((Instruction.user_id == user_id) | Instruction.user_id.is_(None))
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/", response_model=InstructionOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_instruction(body: InstructionCreate, db: AsyncSession = Depends(get_db)):
|
||||
instruction = Instruction(
|
||||
user_id=body.user_id,
|
||||
title=body.title,
|
||||
content=body.content,
|
||||
type=InstructionType(body.type),
|
||||
)
|
||||
db.add(instruction)
|
||||
await db.commit()
|
||||
await db.refresh(instruction)
|
||||
return instruction
|
||||
|
||||
|
||||
@router.put("/{instruction_id}", response_model=InstructionOut)
|
||||
async def update_instruction(
|
||||
instruction_id: int, body: InstructionUpdate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
result = await db.execute(select(Instruction).where(Instruction.id == instruction_id))
|
||||
inst = result.scalar_one_or_none()
|
||||
if not inst:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Instruction not found")
|
||||
|
||||
if body.title is not None:
|
||||
inst.title = body.title
|
||||
if body.content is not None:
|
||||
inst.content = body.content
|
||||
if body.type is not None:
|
||||
inst.type = InstructionType(body.type)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(inst)
|
||||
return inst
|
||||
|
||||
|
||||
@router.delete("/{instruction_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_instruction(instruction_id: int, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Instruction).where(Instruction.id == instruction_id))
|
||||
inst = result.scalar_one_or_none()
|
||||
if not inst:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Instruction not found")
|
||||
|
||||
await db.delete(inst)
|
||||
await db.commit()
|
||||
70
backend/app/routers/users.py
Normal file
70
backend/app/routers/users.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""FluentGerman.ai — User management router (admin only)."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import hash_password, require_admin
|
||||
from app.database import get_db
|
||||
from app.models import User
|
||||
from app.schemas import UserCreate, UserOut, UserUpdate
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["users"], dependencies=[Depends(require_admin)])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[UserOut])
|
||||
async def list_users(db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.is_admin == False).order_by(User.created_at.desc())) # noqa: E712
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(body: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||
# Check uniqueness
|
||||
existing = await db.execute(
|
||||
select(User).where((User.username == body.username) | (User.email == body.email))
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username or email already exists")
|
||||
|
||||
user = User(
|
||||
username=body.username,
|
||||
email=body.email,
|
||||
hashed_password=hash_password(body.password),
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=UserOut)
|
||||
async def update_user(user_id: int, body: UserUpdate, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
|
||||
if body.username is not None:
|
||||
user.username = body.username
|
||||
if body.email is not None:
|
||||
user.email = body.email
|
||||
if body.password is not None:
|
||||
user.hashed_password = hash_password(body.password)
|
||||
if body.is_active is not None:
|
||||
user.is_active = body.is_active
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_user(user_id: int, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
|
||||
await db.delete(user)
|
||||
await db.commit()
|
||||
49
backend/app/routers/voice.py
Normal file
49
backend/app/routers/voice.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""FluentGerman.ai — Admin voice-to-instruction & voice API router."""
|
||||
|
||||
from fastapi import APIRouter, Depends, UploadFile, File
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_admin, get_current_user
|
||||
from app.config import get_settings
|
||||
from app.database import get_db
|
||||
from app.models import User
|
||||
from app.schemas import VoiceConfigOut, VoiceInstructionRequest
|
||||
from app.services.llm_service import summarize_instruction
|
||||
from app.services.voice_service import synthesize, transcribe
|
||||
from fastapi.responses import Response
|
||||
|
||||
router = APIRouter(prefix="/api/voice", tags=["voice"])
|
||||
|
||||
|
||||
@router.get("/config", response_model=VoiceConfigOut)
|
||||
async def voice_config(user: User = Depends(get_current_user)):
|
||||
"""Return current voice mode so frontend knows whether to use browser or API."""
|
||||
return VoiceConfigOut(voice_mode=get_settings().voice_mode)
|
||||
|
||||
|
||||
@router.post("/transcribe")
|
||||
async def transcribe_audio(
|
||||
audio: UploadFile = File(...),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Transcribe uploaded audio to text (API mode only)."""
|
||||
audio_bytes = await audio.read()
|
||||
text = await transcribe(audio_bytes, filename=audio.filename or "audio.webm")
|
||||
return {"text": text}
|
||||
|
||||
|
||||
@router.post("/synthesize")
|
||||
async def synthesize_text(
|
||||
text: str,
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Convert text to speech audio (API mode only)."""
|
||||
audio_bytes = await synthesize(text)
|
||||
return Response(content=audio_bytes, media_type="audio/mpeg")
|
||||
|
||||
|
||||
@router.post("/generate-instruction", dependencies=[Depends(require_admin)])
|
||||
async def generate_instruction(body: VoiceInstructionRequest):
|
||||
"""Admin: takes raw transcript and returns a structured instruction via LLM."""
|
||||
structured = await summarize_instruction(body.raw_text)
|
||||
return {"instruction": structured}
|
||||
86
backend/app/schemas.py
Normal file
86
backend/app/schemas.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""FluentGerman.ai — Pydantic request/response schemas."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
# ── Auth ──────────────────────────────────────────────────────────────
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
# ── User ──────────────────────────────────────────────────────────────
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
username: str | None = None
|
||||
email: EmailStr | None = None
|
||||
password: str | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
email: str
|
||||
is_admin: bool
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ── Instruction ───────────────────────────────────────────────────────
|
||||
class InstructionCreate(BaseModel):
|
||||
user_id: int | None = None
|
||||
title: str
|
||||
content: str
|
||||
type: str = "personal" # global | personal | homework
|
||||
|
||||
|
||||
class InstructionUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
content: str | None = None
|
||||
type: str | None = None
|
||||
|
||||
|
||||
class InstructionOut(BaseModel):
|
||||
id: int
|
||||
user_id: int | None
|
||||
title: str
|
||||
content: str
|
||||
type: str
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ── Chat ──────────────────────────────────────────────────────────────
|
||||
class ChatMessage(BaseModel):
|
||||
role: str # "user" | "assistant"
|
||||
content: str
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
message: str
|
||||
history: list[ChatMessage] = []
|
||||
|
||||
|
||||
# ── Voice / Admin ────────────────────────────────────────────────────
|
||||
class VoiceInstructionRequest(BaseModel):
|
||||
raw_text: str
|
||||
|
||||
|
||||
class VoiceConfigOut(BaseModel):
|
||||
voice_mode: str # "api" | "browser"
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
52
backend/app/services/instruction_service.py
Normal file
52
backend/app/services/instruction_service.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""FluentGerman.ai — Instruction assembly service."""
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Instruction, InstructionType
|
||||
|
||||
|
||||
async def get_system_prompt(db: AsyncSession, user_id: int) -> str:
|
||||
"""Assemble the full system prompt from global + personal + homework instructions."""
|
||||
|
||||
# Global instructions (no user_id)
|
||||
result = await db.execute(
|
||||
select(Instruction).where(
|
||||
Instruction.user_id.is_(None),
|
||||
Instruction.type == InstructionType.GLOBAL,
|
||||
)
|
||||
)
|
||||
global_instructions = result.scalars().all()
|
||||
|
||||
# Personal + homework for this user
|
||||
result = await db.execute(
|
||||
select(Instruction).where(Instruction.user_id == user_id)
|
||||
)
|
||||
user_instructions = result.scalars().all()
|
||||
|
||||
parts: list[str] = []
|
||||
|
||||
if global_instructions:
|
||||
parts.append("=== TEACHING METHOD ===")
|
||||
for inst in global_instructions:
|
||||
parts.append(f"[{inst.title}]\n{inst.content}")
|
||||
|
||||
personal = [i for i in user_instructions if i.type == InstructionType.PERSONAL]
|
||||
if personal:
|
||||
parts.append("\n=== PERSONAL INSTRUCTIONS ===")
|
||||
for inst in personal:
|
||||
parts.append(f"[{inst.title}]\n{inst.content}")
|
||||
|
||||
homework = [i for i in user_instructions if i.type == InstructionType.HOMEWORK]
|
||||
if homework:
|
||||
parts.append("\n=== CURRENT HOMEWORK ===")
|
||||
for inst in homework:
|
||||
parts.append(f"[{inst.title}]\n{inst.content}")
|
||||
|
||||
if not parts:
|
||||
return (
|
||||
"You are a helpful German language tutor. Help the student learn German "
|
||||
"through conversation, corrections, and explanations."
|
||||
)
|
||||
|
||||
return "\n\n".join(parts)
|
||||
65
backend/app/services/llm_service.py
Normal file
65
backend/app/services/llm_service.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""FluentGerman.ai — LLM service (provider-agnostic via LiteLLM)."""
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import litellm
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger("fluentgerman.llm")
|
||||
|
||||
|
||||
def _resolve_model(model: str, provider: str) -> str:
|
||||
"""Ensure Gemini models have the 'gemini/' prefix for Google AI Studio."""
|
||||
if provider == "gemini" and not model.startswith("gemini/"):
|
||||
resolved = f"gemini/{model}"
|
||||
logger.info("Auto-prefixed model: %s → %s (Google AI Studio)", model, resolved)
|
||||
return resolved
|
||||
return model
|
||||
|
||||
|
||||
async def chat_stream(
|
||||
messages: list[dict[str, str]],
|
||||
model: str | None = None,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Stream chat completion tokens from the configured LLM provider."""
|
||||
settings = get_settings()
|
||||
model = _resolve_model(model or settings.llm_model, settings.llm_provider)
|
||||
|
||||
logger.info("LLM request: model=%s messages=%d", model, len(messages))
|
||||
|
||||
response = await litellm.acompletion(
|
||||
model=model,
|
||||
messages=messages,
|
||||
api_key=settings.llm_api_key,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
async for chunk in response:
|
||||
delta = chunk.choices[0].delta
|
||||
if delta.content:
|
||||
yield delta.content
|
||||
|
||||
|
||||
async def summarize_instruction(raw_text: str) -> str:
|
||||
"""Ask the LLM to distill raw voice transcript into a structured instruction."""
|
||||
settings = get_settings()
|
||||
model = _resolve_model(settings.llm_model, settings.llm_provider)
|
||||
|
||||
meta_prompt = (
|
||||
"You are an expert assistant for a language teacher. "
|
||||
"The following text is a raw transcript of the teacher describing a learning instruction, "
|
||||
"homework, or teaching method. Distill it into a clear, concise, structured instruction "
|
||||
"that can be used as a system prompt for a language-learning LLM assistant. "
|
||||
"Output ONLY the instruction text, no preamble.\n\n"
|
||||
f"Transcript:\n{raw_text}"
|
||||
)
|
||||
|
||||
response = await litellm.acompletion(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": meta_prompt}],
|
||||
api_key=settings.llm_api_key,
|
||||
)
|
||||
|
||||
return response.choices[0].message.content.strip()
|
||||
37
backend/app/services/voice_service.py
Normal file
37
backend/app/services/voice_service.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""FluentGerman.ai — Voice service (API provider + browser fallback)."""
|
||||
|
||||
import io
|
||||
|
||||
import openai
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
async def transcribe(audio_bytes: bytes, filename: str = "audio.webm") -> str:
|
||||
"""Transcribe audio to text using OpenAI Whisper API."""
|
||||
settings = get_settings()
|
||||
client = openai.AsyncOpenAI(api_key=settings.llm_api_key)
|
||||
|
||||
audio_file = io.BytesIO(audio_bytes)
|
||||
audio_file.name = filename
|
||||
|
||||
transcript = await client.audio.transcriptions.create(
|
||||
model=settings.stt_model,
|
||||
file=audio_file,
|
||||
)
|
||||
return transcript.text
|
||||
|
||||
|
||||
async def synthesize(text: str) -> bytes:
|
||||
"""Synthesize text to speech using OpenAI TTS API."""
|
||||
settings = get_settings()
|
||||
client = openai.AsyncOpenAI(api_key=settings.llm_api_key)
|
||||
|
||||
response = await client.audio.speech.create(
|
||||
model=settings.tts_model,
|
||||
voice=settings.tts_voice,
|
||||
input=text,
|
||||
response_format="mp3",
|
||||
)
|
||||
|
||||
return response.content
|
||||
2
backend/pyproject.toml
Normal file
2
backend/pyproject.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
19
backend/requirements.txt
Normal file
19
backend/requirements.txt
Normal file
@@ -0,0 +1,19 @@
|
||||
# FluentGerman.ai — Python dependencies
|
||||
fastapi[standard]>=0.115.0
|
||||
uvicorn[standard]>=0.32.0
|
||||
sqlalchemy[asyncio]>=2.0.0
|
||||
asyncpg>=0.30.0
|
||||
pydantic[email]>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
python-jose[cryptography]>=3.3.0
|
||||
bcrypt>=4.0.0
|
||||
litellm>=1.50.0
|
||||
openai>=1.50.0
|
||||
python-multipart>=0.0.9
|
||||
httpx>=0.27.0
|
||||
|
||||
# Testing
|
||||
pytest>=8.0.0
|
||||
pytest-asyncio>=0.24.0
|
||||
aiosqlite>=0.20.0
|
||||
httpx>=0.27.0
|
||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
86
backend/tests/conftest.py
Normal file
86
backend/tests/conftest.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""FluentGerman.ai — Test configuration & fixtures."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.database import Base, get_db
|
||||
from app.main import app
|
||||
|
||||
# Use SQLite for tests (in-memory)
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite:///./test.db"
|
||||
|
||||
|
||||
def get_test_settings() -> Settings:
|
||||
return Settings(
|
||||
database_url=TEST_DATABASE_URL,
|
||||
secret_key="test-secret-key",
|
||||
llm_api_key="test-key",
|
||||
admin_password="testadmin123",
|
||||
)
|
||||
|
||||
|
||||
test_engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||
test_session = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
async def override_get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with test_session() as session:
|
||||
yield session
|
||||
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
app.dependency_overrides[get_settings] = get_test_settings
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def setup_database():
|
||||
async with test_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
async with test_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client() -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def admin_token(client: AsyncClient) -> str:
|
||||
"""Create admin and return token."""
|
||||
from app.auth import hash_password
|
||||
from app.models import User
|
||||
|
||||
async with test_session() as db:
|
||||
admin = User(
|
||||
username="admin",
|
||||
email="admin@test.com",
|
||||
hashed_password=hash_password("admin123"),
|
||||
is_admin=True,
|
||||
)
|
||||
db.add(admin)
|
||||
await db.commit()
|
||||
|
||||
resp = await client.post("/api/auth/login", json={"username": "admin", "password": "admin123"})
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def user_token(client: AsyncClient, admin_token: str) -> str:
|
||||
"""Create a regular user via admin API and return their token."""
|
||||
await client.post(
|
||||
"/api/users/",
|
||||
json={"username": "testuser", "email": "user@test.com", "password": "user123"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
resp = await client.post("/api/auth/login", json={"username": "testuser", "password": "user123"})
|
||||
return resp.json()["access_token"]
|
||||
55
backend/tests/test_auth.py
Normal file
55
backend/tests/test_auth.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""FluentGerman.ai — Auth tests."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_success(client: AsyncClient, admin_token: str):
|
||||
"""Admin can log in and receives a token."""
|
||||
assert admin_token is not None
|
||||
assert len(admin_token) > 20
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_wrong_password(client: AsyncClient):
|
||||
"""Wrong password returns 401."""
|
||||
from app.auth import hash_password
|
||||
from app.models import User
|
||||
from tests.conftest import test_session
|
||||
|
||||
async with test_session() as db:
|
||||
user = User(
|
||||
username="logintest",
|
||||
email="logintest@test.com",
|
||||
hashed_password=hash_password("correct"),
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
|
||||
resp = await client.post("/api/auth/login", json={"username": "logintest", "password": "wrong"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_nonexistent_user(client: AsyncClient):
|
||||
"""Nonexistent user returns 401."""
|
||||
resp = await client.post("/api/auth/login", json={"username": "nobody", "password": "pass"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_me_endpoint(client: AsyncClient, admin_token: str):
|
||||
"""Authenticated user can access /me."""
|
||||
resp = await client.get("/api/auth/me", headers={"Authorization": f"Bearer {admin_token}"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["username"] == "admin"
|
||||
assert data["is_admin"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_me_unauthenticated(client: AsyncClient):
|
||||
"""Unauthenticated request to /me returns 401."""
|
||||
resp = await client.get("/api/auth/me")
|
||||
assert resp.status_code == 401
|
||||
117
backend/tests/test_instructions.py
Normal file
117
backend/tests/test_instructions.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""FluentGerman.ai — Instruction management tests."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_global_instruction(client: AsyncClient, admin_token: str):
|
||||
"""Admin can create a global instruction."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
resp = await client.post(
|
||||
"/api/instructions/",
|
||||
json={"title": "Teaching Method", "content": "Use immersive conversation", "type": "global"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["title"] == "Teaching Method"
|
||||
assert data["type"] == "global"
|
||||
assert data["user_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_personal_instruction(client: AsyncClient, admin_token: str, user_token: str):
|
||||
"""Admin can create a personal instruction for a user."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
# Get user id
|
||||
users_resp = await client.get("/api/users/", headers=headers)
|
||||
user_id = users_resp.json()[0]["id"]
|
||||
|
||||
resp = await client.post(
|
||||
"/api/instructions/",
|
||||
json={
|
||||
"title": "Focus on verbs",
|
||||
"content": "This student needs extra practice with irregular verbs.",
|
||||
"type": "personal",
|
||||
"user_id": user_id,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["user_id"] == user_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_instructions_by_user(client: AsyncClient, admin_token: str, user_token: str):
|
||||
"""Filtering instructions by user returns personal + global."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
users_resp = await client.get("/api/users/", headers=headers)
|
||||
user_id = users_resp.json()[0]["id"]
|
||||
|
||||
# Create global
|
||||
await client.post(
|
||||
"/api/instructions/",
|
||||
json={"title": "Global Rule", "content": "Always respond in German", "type": "global"},
|
||||
headers=headers,
|
||||
)
|
||||
# Create personal
|
||||
await client.post(
|
||||
"/api/instructions/",
|
||||
json={"title": "Personal", "content": "Focus on A1", "type": "personal", "user_id": user_id},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
resp = await client.get(f"/api/instructions/?user_id={user_id}", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
instructions = resp.json()
|
||||
types = [i["type"] for i in instructions]
|
||||
assert "global" in types
|
||||
assert "personal" in types
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_instruction(client: AsyncClient, admin_token: str):
|
||||
"""Admin can update an instruction."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
create_resp = await client.post(
|
||||
"/api/instructions/",
|
||||
json={"title": "Old Title", "content": "Old content", "type": "global"},
|
||||
headers=headers,
|
||||
)
|
||||
inst_id = create_resp.json()["id"]
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/instructions/{inst_id}",
|
||||
json={"title": "New Title", "content": "Updated content"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["title"] == "New Title"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_instruction(client: AsyncClient, admin_token: str):
|
||||
"""Admin can delete an instruction."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
create_resp = await client.post(
|
||||
"/api/instructions/",
|
||||
json={"title": "Delete me", "content": "Temp", "type": "global"},
|
||||
headers=headers,
|
||||
)
|
||||
inst_id = create_resp.json()["id"]
|
||||
|
||||
resp = await client.delete(f"/api/instructions/{inst_id}", headers=headers)
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_admin_cannot_manage_instructions(client: AsyncClient, user_token: str):
|
||||
"""Regular user cannot access instruction endpoints."""
|
||||
headers = {"Authorization": f"Bearer {user_token}"}
|
||||
resp = await client.get("/api/instructions/", headers=headers)
|
||||
assert resp.status_code == 403
|
||||
58
backend/tests/test_llm_service.py
Normal file
58
backend/tests/test_llm_service.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""FluentGerman.ai — LLM service tests (mocked)."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.instruction_service import get_system_prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_prompt_empty():
|
||||
"""When no instructions exist, returns default prompt."""
|
||||
db = AsyncMock()
|
||||
# Mock two queries returning empty results
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = []
|
||||
db.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
prompt = await get_system_prompt(db, user_id=1)
|
||||
assert "German" in prompt
|
||||
assert "tutor" in prompt.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_prompt_with_instructions():
|
||||
"""System prompt includes global and personal instructions."""
|
||||
from app.models import Instruction, InstructionType
|
||||
|
||||
global_inst = MagicMock()
|
||||
global_inst.title = "Method"
|
||||
global_inst.content = "Use immersive conversation"
|
||||
global_inst.type = InstructionType.GLOBAL
|
||||
|
||||
personal_inst = MagicMock()
|
||||
personal_inst.title = "Focus"
|
||||
personal_inst.content = "Practice articles"
|
||||
personal_inst.type = InstructionType.PERSONAL
|
||||
|
||||
db = AsyncMock()
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
result = MagicMock()
|
||||
if call_count == 1:
|
||||
result.scalars.return_value.all.return_value = [global_inst]
|
||||
else:
|
||||
result.scalars.return_value.all.return_value = [personal_inst]
|
||||
return result
|
||||
|
||||
db.execute = mock_execute
|
||||
|
||||
prompt = await get_system_prompt(db, user_id=1)
|
||||
assert "TEACHING METHOD" in prompt
|
||||
assert "immersive conversation" in prompt
|
||||
assert "PERSONAL INSTRUCTIONS" in prompt
|
||||
assert "Practice articles" in prompt
|
||||
95
backend/tests/test_users.py
Normal file
95
backend/tests/test_users.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""FluentGerman.ai — User management tests."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user(client: AsyncClient, admin_token: str):
|
||||
"""Admin can create a new client."""
|
||||
resp = await client.post(
|
||||
"/api/users/",
|
||||
json={"username": "alice", "email": "alice@test.com", "password": "pass123"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["username"] == "alice"
|
||||
assert data["is_admin"] is False
|
||||
assert data["is_active"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_duplicate_user(client: AsyncClient, admin_token: str):
|
||||
"""Duplicate username/email returns 409."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
await client.post(
|
||||
"/api/users/",
|
||||
json={"username": "bob", "email": "bob@test.com", "password": "pass"},
|
||||
headers=headers,
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/users/",
|
||||
json={"username": "bob", "email": "bob2@test.com", "password": "pass"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_users(client: AsyncClient, admin_token: str):
|
||||
"""Admin can list all clients."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
await client.post(
|
||||
"/api/users/",
|
||||
json={"username": "charlie", "email": "charlie@test.com", "password": "pass"},
|
||||
headers=headers,
|
||||
)
|
||||
resp = await client.get("/api/users/", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
users = resp.json()
|
||||
assert len(users) >= 1
|
||||
assert any(u["username"] == "charlie" for u in users)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user(client: AsyncClient, admin_token: str):
|
||||
"""Admin can update a client."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
create_resp = await client.post(
|
||||
"/api/users/",
|
||||
json={"username": "dave", "email": "dave@test.com", "password": "pass"},
|
||||
headers=headers,
|
||||
)
|
||||
user_id = create_resp.json()["id"]
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/users/{user_id}",
|
||||
json={"username": "dave_updated"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["username"] == "dave_updated"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_user(client: AsyncClient, admin_token: str):
|
||||
"""Admin can delete a client."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
create_resp = await client.post(
|
||||
"/api/users/",
|
||||
json={"username": "eve", "email": "eve@test.com", "password": "pass"},
|
||||
headers=headers,
|
||||
)
|
||||
user_id = create_resp.json()["id"]
|
||||
|
||||
resp = await client.delete(f"/api/users/{user_id}", headers=headers)
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_admin_cannot_manage_users(client: AsyncClient, user_token: str):
|
||||
"""Regular user cannot access user management."""
|
||||
headers = {"Authorization": f"Bearer {user_token}"}
|
||||
resp = await client.get("/api/users/", headers=headers)
|
||||
assert resp.status_code == 403
|
||||
Reference in New Issue
Block a user