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

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}