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

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