initial commit

This commit is contained in:
2026-02-12 18:45:10 +01:00
commit be7bbba456
42 changed files with 3767 additions and 0 deletions

33
backend/.env.example Normal file
View 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
View File

61
backend/app/auth.py Normal file
View 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
View 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
View 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
View 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
View 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")

View File

View 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

View 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")

View 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()

View 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()

View 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
View 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"

View File

View 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)

View 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()

View 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
View File

@@ -0,0 +1,2 @@
[tool.pytest.ini_options]
asyncio_mode = "auto"

19
backend/requirements.txt Normal file
View 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

View File

86
backend/tests/conftest.py Normal file
View 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"]

View 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

View 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

View 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

View 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