Sauvegarde de l'état (réparation)

This commit is contained in:
Reviflow Bot committed 2026-01-31 11:00:30 +01:00
commit 7c04fffe77
434 files changed
+83923

No files matched your search

+94
View File
@@ -0,0 +1,94 @@
from enum import Enum
from sqlmodel import SQLModel, Field, Relationship
from typing import Optional, List
import uuid
from datetime import datetime
from pydantic import ConfigDict
class UserRole(str, Enum):
PARENT = "parent"
LEARNER = "learner"
class LearnerProfile(SQLModel, table=True):
__tablename__ = "learner_profiles"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
user_id: uuid.UUID = Field(foreign_key="users.id", index=True, unique=True) # Linked to the SPECIFIC learner user account
first_name: str
avatar_url: Optional[str] = Field(default=None)
# Gamification
streak_current: int = Field(default=0)
streak_max: int = Field(default=0)
last_activity_date: Optional[datetime] = Field(default=None)
xp: int = Field(default=0)
level: int = Field(default=1)
# Relationship back to the specific user account
user: "User" = Relationship(back_populates="learner_profile")
# Badges
# Badges
badges: List["LearnerBadge"] = Relationship(
back_populates="learner",
sa_relationship_kwargs={"lazy": "selectin"}
)
class LearnerBadge(SQLModel, table=True):
__tablename__ = "learner_badges"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
learner_id: uuid.UUID = Field(foreign_key="learner_profiles.id", index=True)
badge_code: str = Field(index=True) # e.g. "FIRST_STEPS", "MATH_CHAMP"
earned_at: datetime = Field(default_factory=datetime.utcnow)
learner: LearnerProfile = Relationship(back_populates="badges")
class User(SQLModel, table=True):
__tablename__ = "users"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
email: str = Field(unique=True, index=True)
username: Optional[str] = Field(default=None, unique=True, index=True)
hashed_password: str
is_active: bool = Field(default=True)
is_superuser: bool = Field(default=False)
is_verified: bool = Field(default=False)
# Additional fields
first_name: Optional[str] = Field(default=None)
role: UserRole = Field(default=UserRole.PARENT)
openrouter_api_key: Optional[str] = Field(default=None)
total_tokens_used: int = Field(default=0)
total_cost_usd: float = Field(default=0.0)
parental_pin: Optional[str] = Field(default=None)
parent_id: Optional[uuid.UUID] = Field(default=None, foreign_key="users.id", index=True)
# Relationships
learner_profile: Optional[LearnerProfile] = Relationship(
back_populates="user",
sa_relationship_kwargs={
"cascade": "all, delete-orphan",
"uselist": False,
"lazy": "selectin"
}
)
children: List["User"] = Relationship(
back_populates="parent",
sa_relationship_kwargs={
"cascade": "all, delete-orphan",
"lazy": "selectin",
"single_parent": True
}
)
parent: Optional["User"] = Relationship(
back_populates="children",
sa_relationship_kwargs={
"remote_side": "User.id",
"lazy": "selectin"
}
)
model_config = ConfigDict(arbitrary_types_allowed=True)
+265
View File
@@ -0,0 +1,265 @@
from fastapi import APIRouter, Depends, HTTPException
import httpx
from app.modules.auth.service import auth_backend, fastapi_users, current_active_user
from app.modules.auth.schemas import UserRead, UserCreate, UserUpdate
from app.modules.auth.models import User, UserRole, LearnerProfile
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy import select
from app.core.db import get_async_session
router = APIRouter()
# /auth/jwt/login
router.include_router(
fastapi_users.get_auth_router(auth_backend),
prefix="/jwt",
tags=["auth"],
)
# /auth/register
router.include_router(
fastapi_users.get_register_router(UserRead, UserCreate),
tags=["auth"],
)
# /users/me
router.include_router(
fastapi_users.get_users_router(UserRead, UserUpdate),
prefix="/users",
tags=["users"],
)
# API Key Validation
@router.get("/validate-api-key", tags=["auth"])
async def validate_api_key(user: User = Depends(current_active_user)):
"""Validate the user's OpenRouter API key by making a test request."""
if not user.openrouter_api_key:
return {"valid": False, "error": "No API key configured"}
try:
async with httpx.AsyncClient() as client:
response = await client.get(
"https://openrouter.ai/api/v1/models",
headers={"Authorization": f"Bearer {user.openrouter_api_key}"},
timeout=10.0
)
if response.status_code == 200:
return {"valid": True}
elif response.status_code == 401:
return {"valid": False, "error": "Invalid API key"}
else:
return {"valid": False, "error": f"API error: {response.status_code}"}
except httpx.TimeoutException:
return {"valid": False, "error": "Connection timeout"}
except Exception as e:
return {"valid": False, "error": str(e)}
@router.post("/verify-parental-gate", tags=["auth"])
async def verify_parental_gate(
payload: dict,
user: User = Depends(current_active_user),
user_manager = Depends(fastapi_users.get_user_manager)
):
"""Verify if the provided PIN or Password is correct to grant parental access."""
pin = payload.get("pin")
password = payload.get("password")
if pin:
if user.parental_pin:
valid, _ = user_manager.password_helper.verify_and_update(pin, user.parental_pin)
if valid:
return {"success": True}
return {"success": False, "error": "Invalid PIN"}
if password:
valid, _ = user_manager.password_helper.verify_and_update(password, user.hashed_password)
if valid:
return {"success": True}
return {"success": False, "error": "Invalid Password"}
raise HTTPException(status_code=400, detail="PIN or Password required")
@router.get("/children", response_model=list[UserRead], tags=["auth"])
async def list_children(
user: User = Depends(current_active_user),
db: AsyncSession = Depends(get_async_session)
):
"""List all children accounts linked to this parent."""
if user.role != UserRole.PARENT:
raise HTTPException(status_code=403, detail="Only parents can list children")
# Re-fetch user with relationships to avoid lazy-loading issues
statement = select(User).where(User.id == user.id).options(
selectinload(User.children)
)
result = await db.execute(statement)
full_user = result.scalar_one()
return full_user.children
@router.get("/profiles", response_model=list[dict], tags=["auth"])
async def list_profiles(
user: User = Depends(current_active_user),
db: AsyncSession = Depends(get_async_session)
):
"""
If parent: list all children's profiles.
If learner: return own profile.
"""
# Re-fetch user with relationships to avoid lazy-loading issues
statement = select(User).where(User.id == user.id).options(
selectinload(User.learner_profile),
selectinload(User.children).selectinload(User.learner_profile)
)
result = await db.execute(statement)
full_user = result.scalar_one()
if full_user.role == UserRole.LEARNER:
if not full_user.learner_profile:
return []
return [{
"id": str(full_user.learner_profile.id),
"first_name": full_user.learner_profile.first_name,
"avatar_url": full_user.learner_profile.avatar_url
}]
# Parent view: collect profiles from all children
profiles = []
for child in full_user.children:
if child.learner_profile:
profiles.append({
"id": str(child.learner_profile.id),
"first_name": child.learner_profile.first_name,
"avatar_url": child.learner_profile.avatar_url,
"username": child.username,
"xp": child.learner_profile.xp,
"level": child.learner_profile.level,
"streak_current": child.learner_profile.streak_current,
"streak_max": child.learner_profile.streak_max,
})
return profiles
@router.patch("/profiles/me", tags=["auth"])
async def update_my_profile(
profile_data: dict,
user: User = Depends(current_active_user),
db: AsyncSession = Depends(get_async_session)
):
"""Allow a learner to update their own profile (e.g. avatar)."""
if user.role != UserRole.LEARNER:
raise HTTPException(status_code=403, detail="Only learners can update their learner profile directly")
# Fetch profile with selectinload to ensure it's available
statement = select(User).where(User.id == user.id).options(selectinload(User.learner_profile))
result = await db.execute(statement)
full_user = result.scalar_one()
if not full_user.learner_profile:
raise HTTPException(status_code=404, detail="Learner profile not found")
profile = full_user.learner_profile
if "first_name" in profile_data:
profile.first_name = profile_data["first_name"]
if "avatar_url" in profile_data:
profile.avatar_url = profile_data["avatar_url"]
db.add(profile)
await db.commit()
await db.refresh(profile)
return {
"id": str(profile.id),
"first_name": profile.first_name,
"avatar_url": profile.avatar_url
}
@router.post("/select-profile/{learner_id}", tags=["auth"])
async def select_profile(
learner_id: str,
user: User = Depends(current_active_user),
db: AsyncSession = Depends(get_async_session)
):
"""Validate that the learner profile belongs to the user."""
statement = select(User).where(User.id == user.id).options(
selectinload(User.learner_profile),
selectinload(User.children).selectinload(User.learner_profile)
)
result = await db.execute(statement)
full_user = result.scalar_one()
profile = None
if full_user.role == UserRole.LEARNER:
if full_user.learner_profile and str(full_user.learner_profile.id) == learner_id:
profile = full_user.learner_profile
else:
# Parent view: check if any child has this profile
for child in full_user.children:
if child.learner_profile and str(child.learner_profile.id) == learner_id:
profile = child.learner_profile
break
if not profile:
raise HTTPException(status_code=404, detail="Profile not found or access denied")
return {"success": True, "profile": {
"id": str(profile.id),
"first_name": profile.first_name,
"avatar_url": profile.avatar_url
}}
@router.post("/profiles", tags=["auth"])
async def create_child_account(
profile_data: dict,
user: User = Depends(current_active_user),
user_manager = Depends(fastapi_users.get_user_manager),
db: AsyncSession = Depends(get_async_session)
):
"""Create a new learner user and their associated profile."""
print(f"DEBUG: create_child_account hit by user {user.email}")
print(f"DEBUG: profile_data: {profile_data}")
if user.role != UserRole.PARENT:
raise HTTPException(status_code=403, detail="Only parents can create child accounts")
username = profile_data.get("username")
if not username:
raise HTTPException(status_code=400, detail="Username is required for child account")
# Create the user entity for the child
try:
child_user = await user_manager.create(
UserCreate(
email=f"{username}@reviflow.app",
password=profile_data.get("password", "1234"), # Default PIN if not provided
username=username,
first_name=profile_data.get("first_name"),
role=UserRole.LEARNER,
parent_id=user.id
)
)
# Create the learner profile
new_profile = LearnerProfile(
user_id=child_user.id,
first_name=profile_data.get("first_name", username),
avatar_url=profile_data.get("avatar_url")
)
db.add(new_profile)
await db.commit()
await db.refresh(new_profile)
return {"success": True, "user": {
"id": str(child_user.id),
"username": child_user.username,
"profile_id": str(new_profile.id)
}}
except Exception as e:
await db.rollback()
import traceback
traceback.print_exc()
detail = str(e)
if hasattr(e, "reason"): detail = e.reason
print(f"DEBUG: Child creation failed: {detail}")
raise HTTPException(status_code=400, detail=f"Échec de la création du compte enfant : {detail}")
+69
View File
@@ -0,0 +1,69 @@
import uuid
from typing import Optional, List
from datetime import datetime
from fastapi_users import schemas
from pydantic import BaseModel, Field
from .models import UserRole
# Learner Profile Schemas
class LearnerProfileBase(BaseModel):
first_name: str = Field(..., min_length=1)
avatar_url: Optional[str] = None
class LearnerProfileCreate(LearnerProfileBase):
pass
class LearnerProfileUpdate(BaseModel):
first_name: Optional[str] = None
avatar_url: Optional[str] = None
class BadgeRead(BaseModel):
id: uuid.UUID
badge_code: str
earned_at: datetime
class Config:
from_attributes = True
class LearnerProfileRead(LearnerProfileBase):
id: uuid.UUID
user_id: uuid.UUID
streak_current: int = 0
streak_max: int = 0
xp: int = 0
level: int = 1
badges: List[BadgeRead] = []
class Config:
from_attributes = True
# User Schemas
class UserRead(schemas.BaseUser[uuid.UUID]):
first_name: Optional[str] = None
username: Optional[str] = None
role: UserRole
openrouter_api_key: Optional[str] = None
total_tokens_used: int = 0
total_cost_usd: float = 0.0
learner_profile: Optional[LearnerProfileRead] = None
parent_id: Optional[uuid.UUID] = None
parental_pin: Optional[str] = None
@property
def has_parental_pin(self) -> bool:
return self.parental_pin is not None
class UserCreate(schemas.BaseUserCreate):
first_name: Optional[str] = None
username: Optional[str] = None
role: UserRole = UserRole.PARENT
parent_id: Optional[uuid.UUID] = None
class UserUpdate(schemas.BaseUserUpdate):
first_name: Optional[str] = None
username: Optional[str] = None
openrouter_api_key: Optional[str] = None
role: Optional[UserRole] = None
parent_id: Optional[uuid.UUID] = None
parental_pin: Optional[str] = None
+104
View File
@@ -0,0 +1,104 @@
import uuid
from typing import Optional
from fastapi import Depends, Request
from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin
from fastapi_users.authentication import AuthenticationBackend, BearerTransport, JWTStrategy
from fastapi_users.db import SQLAlchemyUserDatabase
from app.config import settings
from app.core.db import get_async_session
from app.modules.auth.models import User, UserRole
# 1. Database Adaptor
class UserDatabase(SQLAlchemyUserDatabase):
async def get_by_username(self, username: str) -> Optional[User]:
from sqlalchemy import select
statement = select(User).where(User.username == username)
result = await self.session.execute(statement)
return result.scalar_one_or_none()
async def get_user_db(session=Depends(get_async_session)):
yield UserDatabase(session, User)
# 2. User Manager (Business Logic)
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
reset_password_token_secret = settings.SECRET_KEY
verification_token_secret = settings.SECRET_KEY
async def on_after_register(self, user: User, request: Optional[Request] = None):
print(f"User {user.id} has registered as {user.role}.")
# If learner, ensure they have a profile
if user.role == UserRole.LEARNER:
# This will be handled in the custom creation endpoint mostly,
# but good to have a safeguard
pass
async def validate_password(self, password: str, user: Optional[User] = None) -> None:
"""Relax validation to allow 4-digit PINs."""
if len(password) < 4:
from fastapi_users.exceptions import InvalidPasswordException
raise InvalidPasswordException("Password must be at least 4 characters long")
async def authenticate(self, credentials):
"""Override authenticate to check both email and username."""
if isinstance(credentials, dict):
username = credentials.get("username")
password = credentials.get("password")
else:
username = getattr(credentials, "username", None)
password = getattr(credentials, "password", None)
if not username or not password:
return None
# Try finding by email (Standard)
try:
user = await self.get_by_email(username)
if user and self.password_helper.verify_and_update(password, user.hashed_password)[0]:
return user
except Exception:
pass
# Try finding by username (Learner)
try:
# We use a custom query for username as BaseUserManager doesn't have get_by_username
user = await self.user_db.get_by_username(username)
if user and self.password_helper.verify_and_update(password, user.hashed_password)[0]:
return user
except Exception:
pass
return None
async def on_after_update(self, user: User, update_dict: dict, request: Optional[Request] = None):
if "parental_pin" in update_dict and update_dict["parental_pin"]:
# We don't hash it here because FastAPI Users doesn't automatically call a hook BEFORE update for custom fields easily without overriding update()
pass
async def update(self, user_update, user, safe=True, request=None):
# Hash parental_pin if it's being updated
if hasattr(user_update, "parental_pin") and user_update.parental_pin is not None:
user_update.parental_pin = self.password_helper.hash(user_update.parental_pin)
return await super().update(user_update, user, safe=safe, request=request)
async def get_user_manager(user_db=Depends(get_user_db)):
yield UserManager(user_db)
# 3. Authentication Backend (Cookie/JWT)
bearer_transport = BearerTransport(tokenUrl="auth/jwt/login")
def get_jwt_strategy() -> JWTStrategy:
return JWTStrategy(secret=settings.SECRET_KEY, lifetime_seconds=3600*24) # 24h Session
auth_backend = AuthenticationBackend(
name="jwt",
transport=bearer_transport,
get_strategy=get_jwt_strategy,
)
# 4. FastAPI Users Instance
fastapi_users = FastAPIUsers[User, uuid.UUID](
get_user_manager,
[auth_backend],
)
current_active_user = fastapi_users.current_user(active=True)
+174
View File
@@ -0,0 +1,174 @@
"""Ingest module router for image analysis."""
import json
import asyncio
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from app.modules.ingest.schemas import AnalyzeRequest, AnalyzeResponse, AnalyzeError
from app.modules.ingest.service import analyze_documents
from app.modules.auth.service import current_active_user
from typing import Optional
from app.modules.auth.models import User, UserRole
from app.core.db import get_async_session
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.config import settings
router = APIRouter()
async def get_effective_api_key(user: User, db: AsyncSession) -> Optional[str]:
"""Retrieve the API key from user, parent, or global settings."""
# 1. User specific key
if user.openrouter_api_key:
return user.openrouter_api_key
# 2. Parent's key (if learner)
if user.parent_id:
statement = select(User).where(User.id == user.parent_id)
result = await db.execute(statement)
parent = result.scalar_one_or_none()
if parent and parent.openrouter_api_key:
return parent.openrouter_api_key
# 3. Global settings key
if settings.OPENROUTER_API_KEY:
return settings.OPENROUTER_API_KEY
return None
@router.post(
"/analyze",
response_model=AnalyzeResponse,
responses={
400: {"model": AnalyzeError, "description": "Invalid request"},
401: {"model": AnalyzeError, "description": "No API key configured"},
500: {"model": AnalyzeError, "description": "AI service error"}
}
)
async def analyze_image_endpoint(
request: AnalyzeRequest,
user: User = Depends(current_active_user),
db: AsyncSession = Depends(get_async_session)
):
"""
Analyze an image using AI Vision to extract lesson content.
Requires a valid OpenRouter API key configured in user settings.
"""
# Determine which API key to use
api_key = await get_effective_api_key(user, db)
if not api_key:
raise HTTPException(
status_code=401,
detail="No OpenRouter API key configured. Please add your API key in Settings or contact your administrator."
)
if not request.images_base64:
raise HTTPException(
status_code=400,
detail="No images provided"
)
try:
result, math_safety_triggered = await analyze_documents(
request.images_base64,
api_key
)
# Update usage
usage = result.get("usage", {})
user.total_tokens_used += usage.get("total_tokens", 0)
# Approximate cost (0.1$ / 1M tokens for Flash)
user.total_cost_usd += usage.get("total_tokens", 0) * 0.0000001
db.add(user)
await db.commit()
return AnalyzeResponse(**result)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"AI analysis failed: {str(e)}"
)
@router.post("/analyze-stream")
async def analyze_image_stream(
request: AnalyzeRequest,
user: User = Depends(current_active_user),
db: AsyncSession = Depends(get_async_session)
):
"""
Analyze an image with Server-Sent Events for progress updates.
Streams progress events: uploading -> reading -> analyzing -> complete
"""
# Determine which API key to use
api_key = await get_effective_api_key(user, db)
if not api_key:
raise HTTPException(
status_code=401,
detail="No OpenRouter API key configured. Please add your API key in Settings or contact your administrator."
)
if not request.images_base64:
raise HTTPException(
status_code=400,
detail="No images provided"
)
async def event_generator():
try:
# Step 1: Uploading
yield f"data: {json.dumps({'step': 'uploading', 'message': 'Téléchargement...', 'progress': 10})}\n\n"
await asyncio.sleep(0.1)
# Step 2: Reading (OCR)
msg = {'step': 'reading', 'message': "Lecture des documents...", 'progress': 30}
yield f"data: {json.dumps(msg)}\n\n"
await asyncio.sleep(0.1)
# Step 3: Analyzing
yield f"data: {json.dumps({'step': 'analyzing', 'message': 'Analyse IA en cours...', 'progress': 50})}\n\n"
# Actually perform the analysis
result, math_safety_triggered = await analyze_documents(
request.images_base64,
api_key
)
# Update usage
usage = result.get("usage", {})
user.total_tokens_used += usage.get("total_tokens", 0)
user.total_cost_usd += usage.get("total_tokens", 0) * 0.0000001
db.add(user)
await db.commit()
# Step 4: Synthesizing
yield f"data: {json.dumps({'step': 'synthesizing', 'message': 'Génération de la synthèse...', 'progress': 80})}\n\n"
await asyncio.sleep(0.1)
# Step 5: Complete
yield f"data: {json.dumps({'step': 'complete', 'message': 'Terminé!', 'progress': 100, 'result': result})}\n\n"
except Exception as e:
import traceback
error_msg = f"Error in analyze_image_stream: {str(e)}\n{traceback.format_exc()}"
print(error_msg) # Log to console
yield f"data: {json.dumps({'step': 'error', 'message': str(e), 'progress': 0})}\n\n"
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no"
}
)
+19
View File
@@ -0,0 +1,19 @@
from pydantic import BaseModel
from typing import Optional, List
class AnalyzeRequest(BaseModel):
images_base64: list[str] # List of base64 encoded image data
class AnalyzeResponse(BaseModel):
title: str
subject: str
raw_text: str
synthesis: str
study_tips: List[str] = []
is_math_content: bool = False
math_safety_triggered: bool = False
usage: Optional[dict] = None
class AnalyzeError(BaseModel):
error: str
detail: Optional[str] = None
+163
View File
@@ -0,0 +1,163 @@
"""AI Vision Service using OpenRouter API."""
import base64
import json
import re
from typing import Tuple
import httpx
# Constants
OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"
DEFAULT_MODEL = "google/gemini-2.5-flash" # Verified OpenRouter ID
# Math Safety keywords
MATH_KEYWORDS = [
"équation", "equation", "calcul", "calculate", "résoudre", "solve",
"formule", "formula", "mathématique", "mathematics", "algèbre", "algebra",
"géométrie", "geometry", "dérivée", "derivative", "intégrale", "integral",
"∫", "∑", "∏", "√", "π", "θ", "∆", "∂"
]
SYSTEM_PROMPT = """You are an educational content analyzer for French students.
Analyze the provided image of a lesson/course material and extract:
1. **title**: A concise title for this lesson (max 10 words)
2. **subject**: The academic subject (e.g., "Histoire", "Sciences", "Français", "Mathématiques")
3. **raw_text**: The complete text extracted from the image, preserving structure
4. **synthesis**: A clear, student-friendly summary of the key concepts (3-5 bullet points)
⚠️ MATH SAFETY RULE: If this content contains math problems, equations, or exercises:
- Extract the text/equations as-is in raw_text
- In synthesis, only EXPLAIN the concepts, DO NOT solve any problems
- Set is_math_content to true
Respond in JSON format with this exact structure:
{
"title": "string",
"subject": "string",
"raw_text": "string",
"synthesis": "string",
"study_tips": ["string", "string", "string"],
"is_math_content": boolean
}
Always respond in French."""
async def _analyze_batch(batch_images: list[str], api_key: str) -> dict:
"""Helper to analyze a batch of images."""
content_payload = [
{
"type": "text",
"text": "Analyze these lesson documents and extract the structured content."
}
]
for img_b64 in batch_images:
# Clean base64 if it has data URL prefix
if "base64," in img_b64:
img_b64 = img_b64.split("base64,")[1]
content_payload.append({
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{img_b64}"
}
})
payload = {
"model": DEFAULT_MODEL,
"messages": [
{
"role": "system",
"content": SYSTEM_PROMPT
},
{
"role": "user",
"content": content_payload
}
],
"response_format": {"type": "json_object"},
"max_tokens": 8192
}
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"HTTP-Referer": "https://reviflow.app",
"X-Title": "Reviflow"
}
async with httpx.AsyncClient() as client:
response = await client.post(
OPENROUTER_API_URL,
json=payload,
headers=headers,
timeout=120.0
)
if response.status_code != 200:
error_detail = response.text
raise Exception(f"OpenRouter API error ({response.status_code}): {error_detail}")
result = response.json()
content = result["choices"][0]["message"]["content"]
try:
analysis = json.loads(content)
return analysis
except json.JSONDecodeError:
json_match = re.search(r'```json\s*(.*?)\s*```', content, re.DOTALL)
if json_match:
return json.loads(json_match.group(1))
else:
print(f"FAILED CONTENT PREVIEW: {content[:500]}...")
raise Exception("Failed to parse AI response as JSON")
async def analyze_documents(images_base64: list[str], api_key: str) -> Tuple[dict, bool]:
"""
Analyze multiple images using OpenRouter Vision API with batching.
"""
BATCH_SIZE = 5
batches = [images_base64[i:i + BATCH_SIZE] for i in range(0, len(images_base64), BATCH_SIZE)]
full_analysis = {
"title": "Sans titre",
"subject": "Général",
"raw_text": "",
"synthesis": "",
"is_math_content": False,
"usage": {}
}
print(f"Processing {len(images_base64)} images in {len(batches)} batches...")
for index, batch in enumerate(batches):
print(f"Analyzing batch {index + 1}/{len(batches)}...")
try:
batch_result = await _analyze_batch(batch, api_key)
# For the first batch, take title and subject
if index == 0:
full_analysis["title"] = batch_result.get("title", "Sans titre")
full_analysis["subject"] = batch_result.get("subject", "Général")
# Accumulate text and synthesis
chunk_text = batch_result.get("raw_text", "")
chunk_synthesis = batch_result.get("synthesis", "")
full_analysis["raw_text"] += f"\n\n--- Partie {index + 1} ---\n" + chunk_text
full_analysis["synthesis"] += f"\n" + chunk_synthesis
# Check for math (if any batch has math, whole doc is math)
if batch_result.get("is_math_content", False) or \
any(k in chunk_text.lower() for k in MATH_KEYWORDS):
full_analysis["is_math_content"] = True
except Exception as e:
print(f"Error processing batch {index + 1}: {str(e)}")
# Continue with what we have, or could fail?
# Ideally fail to warn user, but for now lets re-raise to be safe
raise e
return full_analysis, full_analysis["is_math_content"]
+48
View File
@@ -0,0 +1,48 @@
from sqlmodel import SQLModel, Field
from typing import Optional
from datetime import datetime
import uuid
class Score(SQLModel, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
user_id: uuid.UUID = Field(index=True)
topic: str
score: int
total_questions: int
learner_id: Optional[uuid.UUID] = Field(default=None, index=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
revision_id: Optional[uuid.UUID] = Field(default=None, index=True)
class RemediationQueue(SQLModel, table=True):
__tablename__ = "remediation_queue"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
learner_id: uuid.UUID = Field(index=True)
original_content: str # Context or snippet
question: str
wrong_answer: str
correct_answer: str
topic: str
created_at: datetime = Field(default_factory=datetime.utcnow)
status: str = Field(default="PENDING") # PENDING, REVIEWED, MASTERED
revision_id: Optional[uuid.UUID] = Field(default=None, index=True)
class Revision(SQLModel, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
learner_id: Optional[uuid.UUID] = Field(index=True)
topic: str
subject: Optional[str] = None # Added for categorization
text_content: str # The full lesson text
synthesis: Optional[str] = None # AI Summary
study_tips: Optional[str] = None # JSON string of tips list
quiz_data: Optional[str] = None # JSON string of current/last quiz
progress_state: Optional[str] = None # JSON string: {index, answers, score}
# Session / Series Management
status: str = Field(default="NEW") # NEW, IN_PROGRESS, COMPLETED
current_series: int = Field(default=1)
completed_series: int = Field(default=0) # Track how many parts are finished
total_series: int = Field(default=1)
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
+852
View File
@@ -0,0 +1,852 @@
from fastapi import APIRouter, Depends, HTTPException, Body
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List, Optional
import uuid
import json as import_json
from datetime import datetime
from app.core.db import get_async_session
from app.modules.auth.service import current_active_user
from app.modules.auth.models import User
from app.modules.quiz.schemas import QuizRequest, QuizResponse, ScoreCreate, ScoreResponse
from pydantic import BaseModel, Field as PydanticField
from typing import Any
from app.modules.quiz.models import Score
from app.modules.quiz.service import generate_quiz
from sqlmodel import select, delete
from app.config import settings
router = APIRouter()
async def get_effective_api_key(user: User, db: AsyncSession) -> Optional[str]:
"""Retrieve the API key from user, parent, or global settings."""
# 1. User specific key
if user.openrouter_api_key:
return user.openrouter_api_key
# 2. Parent's key (if learner)
if user.parent_id:
# Use simple select for Parent
statement = select(User).where(User.id == user.parent_id)
result = await db.execute(statement)
parent = result.scalar_one_or_none()
if parent and parent.openrouter_api_key:
return parent.openrouter_api_key
# 3. Global settings key
if settings.OPENROUTER_API_KEY:
return settings.OPENROUTER_API_KEY
return None
@router.post("/generate", response_model=QuizResponse)
async def generate_quiz_endpoint(
request: QuizRequest,
user: User = Depends(current_active_user),
db: AsyncSession = Depends(get_async_session)
):
"""Generates a quiz from the provided text content."""
# Determine which API key to use
api_key = await get_effective_api_key(user, db)
if not api_key:
raise HTTPException(
status_code=401,
detail="No OpenRouter API key configured. Please add your API key in Settings or contact your administrator."
)
try:
data = await generate_quiz(request.text_content, api_key, request.difficulty)
# Save Revision
from app.modules.quiz.models import Revision
revision = Revision(
learner_id=request.learner_id, # Optional
topic=request.title if request.title else data["quiz"]["topic"],
subject=request.subject,
text_content=request.text_content,
synthesis=request.synthesis,
study_tips=import_json.dumps(request.study_tips) if request.study_tips else None,
quiz_data=import_json.dumps(data["quiz"]), # Save as JSON string
created_at=datetime.utcnow(),
total_series=data.get("meta", {}).get("total_series", 1)
)
db.add(revision)
# Update usage
usage = data.get("usage", {})
user.total_tokens_used += usage.get("total_tokens", 0)
user.total_cost_usd += usage.get("total_tokens", 0) * 0.0000001
db.add(user)
await db.commit()
await db.refresh(revision)
response_quiz = data["quiz"]
response_quiz["revision_id"] = revision.id # Add revision_id to response
response_quiz["series_info"] = {
"current": 1,
"total": revision.total_series
}
return response_quiz
except Exception as e:
import traceback
print(f"Error in generate_quiz_endpoint: {str(e)}\n{traceback.format_exc()}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/score", response_model=ScoreResponse)
async def save_score(
score_data: ScoreCreate,
user: User = Depends(current_active_user),
db: AsyncSession = Depends(get_async_session)
):
"""Saves a quiz score."""
from app.modules.quiz.models import RemediationQueue
score = Score(
user_id=user.id,
topic=score_data.topic,
score=score_data.score,
total_questions=score_data.total_questions,
learner_id=score_data.learner_id,
revision_id=score_data.revision_id # Link to revision
)
db.add(score)
# --- Streak Logic ---
if score_data.learner_id:
from app.modules.auth.models import LearnerProfile
from datetime import datetime, timedelta
learner = await db.get(LearnerProfile, score_data.learner_id)
if learner:
today = datetime.utcnow().date()
last_activity = learner.last_activity_date.date() if learner.last_activity_date else None
if last_activity == today:
pass # Already active today
elif last_activity == today - timedelta(days=1):
learner.streak_current += 1
else:
learner.streak_current = 1 # Reset or first time
if learner.streak_current > learner.streak_max:
learner.streak_max = learner.streak_current
# XP and Level Logic
# 1 XP per point scored? Or fixed amount per quiz?
# Let's say XP = Score achieved.
xp_gained = score_data.score
learner.xp += xp_gained
# Level Formula: Level = 1 + floor(sqrt(XP / 50))
# Level 1: 0-49 XP
# Level 2: 50-199 XP
# Level 3: 200-449 XP
# ...
import math
new_level = 1 + math.floor(math.sqrt(learner.xp / 50))
if new_level > learner.level:
# Level Up! We could notify frontend here too via new_badges or similar
learner.level = int(new_level)
learner.last_activity_date = datetime.utcnow()
db.add(learner)
# --- Badge Logic ---
# Define badge constants
BADGE_FIRST_STEPS = "FIRST_STEPS" # First quiz completed
BADGE_NIGHT_OWL = "NIGHT_OWL" # Quiz after 8 PM (20:00)
BADGE_MATH_CHAMP = "MATH_CHAMP" # Score > 80% in Math
BADGE_ON_FIRE = "ON_FIRE" # Streak >= 3
# Get existing badges
from app.modules.auth.models import LearnerBadge
result = await db.execute(select(LearnerBadge.badge_code).where(LearnerBadge.learner_id == learner.id))
existing_badges = result.scalars().all()
new_badges_list = []
async def award_badge(code: str):
if code not in existing_badges:
badge = LearnerBadge(learner_id=learner.id, badge_code=code)
db.add(badge)
new_badges_list.append(code)
# Check conditions
await award_badge(BADGE_FIRST_STEPS)
if datetime.utcnow().hour >= 20: # Simple logic for Night Owl (UTC based for now, ideally TZ aware)
await award_badge(BADGE_NIGHT_OWL)
if "math" in score_data.topic.lower() and (score_data.score / score_data.total_questions) >= 0.8:
await award_badge(BADGE_MATH_CHAMP)
if learner.streak_current >= 3:
await award_badge(BADGE_ON_FIRE)
# -------------------
# --- Remediation Logic ---
# 1. If this IS a remediation quiz (detected by topic), close old pending errors
# Robust check: case insensitive, check for 'remedia'
topic_lower = score_data.topic.lower()
if "remedia" in topic_lower:
# Mark pending items for this revision/topic as REVIEWED
# We assume that taking the quiz counts as reviewing them.
# If they fail again, the code below (step 2) will add new items.
update_stmt = select(RemediationQueue).where(
RemediationQueue.learner_id == learner.id,
RemediationQueue.status == "PENDING"
)
if score_data.revision_id:
update_stmt = update_stmt.where(RemediationQueue.revision_id == score_data.revision_id)
# Fetch and update
pending_results = await db.execute(update_stmt)
pending_items = pending_results.scalars().all()
for item in pending_items:
item.status = "REVIEWED"
db.add(item)
# 2. Add NEW errors to queue
if score_data.details:
for detail in score_data.details:
if not detail.is_correct:
# Add to remediation queue
remediation_item = RemediationQueue(
learner_id=learner.id,
original_content=detail.original_content or score_data.topic, # Use topic if context missing
question=detail.question,
wrong_answer=detail.user_answer,
correct_answer=detail.correct_answer,
topic=score_data.topic,
revision_id=score_data.revision_id # Link error to specific revision
)
db.add(remediation_item)
# -------------------------
await db.commit()
await db.refresh(score)
# --- Series Status Update ---
if score_data.revision_id:
from app.modules.quiz.models import Revision
revision = await db.get(Revision, score_data.revision_id)
if revision:
# Mark current series as completed
if revision.current_series > revision.completed_series:
revision.completed_series = revision.current_series
# Check if this was the last series
if revision.completed_series >= revision.total_series:
revision.status = "COMPLETED"
db.add(revision)
await db.commit()
# Fix: Clear progress state as the series is finished
# This prevents the specific bug where "Resuming" loads the old progress instead of showing the ResultCard
if revision.progress_state:
revision.progress_state = None
db.add(revision)
await db.commit()
# ----------------------------
# ----------------------------
# Prepare response
response = ScoreResponse(
id=score.id,
topic=score.topic,
score=score.score,
total_questions=score.total_questions,
created_at=score.created_at,
learner_id=score.learner_id,
new_badges=new_badges_list if score_data.learner_id else [],
revision_id=score.revision_id
)
return response
class ProgressUpdate(BaseModel):
revision_id: uuid.UUID
current_index: int
answers: List[Any]
score: int
@router.post("/progress/save")
async def save_progress(
data: ProgressUpdate,
user: User = Depends(current_active_user),
db: AsyncSession = Depends(get_async_session)
):
"""Saves the current progress of a revision quiz."""
from app.modules.quiz.models import Revision
revision = await db.get(Revision, data.revision_id)
if not revision:
raise HTTPException(status_code=404, detail="Revision not found")
# Save state as JSON
state = {
"current_index": data.current_index,
"answers": data.answers,
"score": data.score,
"timestamp": str(datetime.utcnow())
}
revision.progress_state = import_json.dumps(state)
revision.status = "IN_PROGRESS"
db.add(revision)
await db.commit()
return {"status": "success"}
@router.post("/next-series")
async def start_next_series(
revision_id: uuid.UUID = Body(..., embed=True),
user: User = Depends(current_active_user),
db: AsyncSession = Depends(get_async_session)
):
"""Generates and loads the NEXT series of questions for a revision."""
api_key = await get_effective_api_key(user, db)
if not api_key:
raise HTTPException(status_code=401, detail="No API Key")
from app.modules.quiz.models import Revision
revision = await db.get(Revision, revision_id)
if not revision:
raise HTTPException(status_code=404, detail="Revision not found")
if revision.current_series >= revision.total_series:
raise HTTPException(status_code=400, detail="Already at the last series.")
# Increment Series
next_series = revision.current_series + 1
try:
# Generate new quiz for the next series
data = await generate_quiz(
revision.text_content,
api_key,
series_index=next_series
)
# Update Revision
revision.current_series = next_series
revision.quiz_data = import_json.dumps(data["quiz"])
revision.progress_state = None # Clear previous progress
revision.status = "IN_PROGRESS"
db.add(revision)
await db.commit()
await db.refresh(revision)
# Return new quiz
response_quiz = data["quiz"]
response_quiz["revision_id"] = revision.id
# Add meta for frontend to know series state
response_quiz["series_info"] = {
"current": next_series,
"total": revision.total_series
}
return response_quiz
except Exception as e:
import traceback
print(f"Error in next_series: {e}")
raise HTTPException(status_code=500, detail="Failed to generate next series.")
@router.get("/review/{revision_id}")
async def get_revision(
revision_id: uuid.UUID,
user: User = Depends(current_active_user),
db: AsyncSession = Depends(get_async_session)
):
"""Retrieves a full revision (content + quiz) by ID."""
from app.modules.quiz.models import Revision
revision = await db.get(Revision, revision_id)
if not revision:
raise HTTPException(status_code=404, detail="Revision not found")
return revision
@router.get("/revisions")
async def list_revisions(
learner_id: Optional[uuid.UUID] = None,
user: User = Depends(current_active_user),
db: AsyncSession = Depends(get_async_session)
):
"""Lists all revisions for a learner, with pending error counts."""
from app.modules.quiz.models import Revision, RemediationQueue
from sqlalchemy import func
# 1. Base query for revisions
stmt = select(Revision).where(Revision.learner_id == learner_id).order_by(Revision.created_at.desc())
result = await db.execute(stmt)
revisions = result.scalars().all()
# 2. Add pending error counts
# Fetch all pending error counts in one go for efficiency
remed_stmt = select(
RemediationQueue.revision_id,
func.count(RemediationQueue.id)
).where(
RemediationQueue.learner_id == learner_id,
RemediationQueue.status == "PENDING",
RemediationQueue.revision_id.isnot(None)
).group_by(RemediationQueue.revision_id)
remed_result = await db.execute(remed_stmt)
remed_map = {row[0]: row[1] for row in remed_result.all()}
final_list = []
for rev in revisions:
rev_dict = rev.model_dump() # SQLModel uses model_dump() in newer versions or dict()
# For safety across versions
if hasattr(rev, "model_dump"):
rev_dict = rev.model_dump()
else:
rev_dict = rev.dict()
rev_dict['pending_errors'] = remed_map.get(rev.id, 0)
# Ensure dates are serialized if needed, but FastAPI handles this
final_list.append(rev_dict)
return final_list
@router.get("/history", response_model=List[ScoreResponse])
async def get_history(
learner_id: Optional[uuid.UUID] = None,
user: User = Depends(current_active_user),
db: AsyncSession = Depends(get_async_session)
):
"""Retrieves user score history."""
# Filter by user_id AND learner_id (if provided or None for main profile)
statement = select(Score).where(
Score.user_id == user.id,
Score.learner_id == learner_id
).order_by(Score.created_at.desc())
result = await db.execute(statement)
return result.scalars().all()
@router.get("/remediation/count")
async def get_remediation_count(
learner_id: uuid.UUID,
user: User = Depends(current_active_user),
db: AsyncSession = Depends(get_async_session)
):
"""Returns number of pending remediation items."""
from app.modules.quiz.models import RemediationQueue
# Verify learner belongs to user (or is user) - simple check
# In full app we'd check if learner_id in user.learner_profiles
statement = select(RemediationQueue).where(
RemediationQueue.learner_id == learner_id,
RemediationQueue.status == "PENDING"
)
result = await db.execute(statement)
items = result.scalars().all()
return {"count": len(items)}
@router.post("/remediation/generate", response_model=QuizResponse)
async def generate_remediation_quiz(
learner_id: uuid.UUID = Body(..., embed=True),
revision_id: Optional[uuid.UUID] = Body(None, embed=True),
user: User = Depends(current_active_user),
db: AsyncSession = Depends(get_async_session)
):
"""Generates a quiz based on pending errors."""
# Determine which API key to use
api_key = await get_effective_api_key(user, db)
if not api_key:
raise HTTPException(
status_code=401,
detail="No OpenRouter API key configured. Please add your API key in Settings or contact your administrator."
)
from app.modules.quiz.models import RemediationQueue
# 1. Fetch pending items
conditions = [
RemediationQueue.learner_id == learner_id,
RemediationQueue.status == "PENDING"
]
if revision_id:
conditions.append(RemediationQueue.revision_id == revision_id)
# Fetch a pool of recent errors (e.g. 20) then randomize selection
statement = select(RemediationQueue).where(*conditions).order_by(RemediationQueue.created_at.desc()).limit(20)
result = await db.execute(statement)
items = result.scalars().all()
# Convert to list to shuffle
items = list(items)
import random
random.shuffle(items)
# Review ALL fetched items (up to limit), not just 5.
# items = items[:5]
if len(items) < 1:
raise HTTPException(status_code=400, detail="Not enough errors to generate a quiz.")
# 2. Build context
remediation_context = [
{
"question": item.question,
"wrong_answer": item.wrong_answer,
"correct_answer": item.correct_answer,
"context": item.original_content
} for item in items
]
# 3. Fetch source text if Revision ID is explicitly provided
source_text = None
if revision_id:
from app.modules.quiz.models import Revision
rev = await db.get(Revision, revision_id)
if rev:
source_text = rev.text_content
# 4. Generate Quiz
try:
# We need to expose a generic generate function or specific one
from app.modules.quiz.service import generate_remediation_quiz_service
data = await generate_remediation_quiz_service(remediation_context, api_key, source_text=source_text)
# Update usage
usage = data.get("usage", {})
user.total_tokens_used += usage.get("total_tokens", 0)
user.total_cost_usd += usage.get("total_tokens", 0) * 0.0000001
db.add(user)
await db.commit()
quiz_content = data["quiz"]
# Force topic to indicate remediation for reliable detection downstream
if "remedia" not in quiz_content.get("topic", "").lower():
original_topic = quiz_content.get("topic", "Révision")
quiz_content["topic"] = f"{original_topic} (Remediation)"
# Inject revision_id if present so it persists through the quiz lifecycle
if revision_id:
quiz_content["revision_id"] = str(revision_id)
return quiz_content
except Exception as e:
import traceback
print(f"Error in generate_remediation_quiz: {str(e)}\n{traceback.format_exc()}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/stats/mastery")
async def get_mastery_stats(
learner_id: uuid.UUID,
user: User = Depends(current_active_user),
db: AsyncSession = Depends(get_async_session)
):
"""Calculates mastery level per topic."""
from app.modules.quiz.models import RemediationQueue
# 1. Get all scores
scores_stmt = select(Score).where(Score.learner_id == learner_id)
result = await db.execute(scores_stmt)
scores = result.scalars().all()
if not scores:
return []
# 2. Group by Normalized Topic
# We want to merge "Maths" and "Maths (Remediation)" into "Maths"
# And we want to prioritize RECENT scores.
topic_map = {}
for s in scores:
# Normalize topic
clean_topic = s.topic.replace(" (Remediation)", "").replace(" (Remédiation)", "").strip()
if clean_topic not in topic_map:
topic_map[clean_topic] = []
topic_map[clean_topic].append(s)
# 3. Calculate Mastery per Topic using Weighted Average of last 5 attempts
mastery_list = []
# Pre-fetch errors to avoid N+1 queries
errors_stmt = select(RemediationQueue).where(
RemediationQueue.learner_id == learner_id,
RemediationQueue.status == "PENDING"
)
result = await db.execute(errors_stmt)
errors = result.scalars().all()
error_counts = {}
for e in errors:
# Also clean topic for errors if needed, or rely on exact match?
# Ideally errors should also be grouped by clean topic.
t_key = e.topic.replace(" (Remediation)", "").replace(" (Remédiation)", "").strip()
error_counts[t_key] = error_counts.get(t_key, 0) + 1
for topic, topic_scores in topic_map.items():
# Sort by date asc (oldest first)
topic_scores.sort(key=lambda x: x.created_at)
# Take last 5 scores
recent_scores = topic_scores[-5:]
# Calculate Weighted Average
# Example: [50, 60, 70] -> (50*1 + 60*2 + 70*3) / (1+2+3)
total_weight = 0
weighted_sum = 0
for i, s in enumerate(recent_scores):
weight = i + 1
# Percentage for this quiz
pct = (s.score / s.total_questions) * 100 if s.total_questions > 0 else 0
weighted_sum += pct * weight
total_weight += weight
base_mastery = weighted_sum / total_weight if total_weight > 0 else 0
# Apply penalty for pending errors
pending_errors = error_counts.get(topic, 0)
penalty = pending_errors * 3 # Reduced penalty from 5 to 3
final_mastery = max(0, min(100, base_mastery - penalty))
status = "LEARNING"
if final_mastery >= 80:
status = "MASTERED"
elif final_mastery >= 50:
status = "REVIEWING"
last_activity = topic_scores[-1].created_at
# Find the latest revision for this topic to get synthesis and tips
latest_revision = None
# topic_scores are already sorted by date asc, so let's find latest revision
# Actually, let's find the latest revision object directly.
# Revision might have a different topic name? No, normalization should match.
from app.modules.quiz.models import Revision
rev_stmt = select(Revision).where(
Revision.learner_id == learner_id,
Revision.topic == topic
).order_by(Revision.created_at.desc()).limit(1)
rev_result = await db.execute(rev_stmt)
latest_revision = rev_result.scalar_one_or_none()
mastery_list.append({
"topic": topic,
"mastery_score": int(final_mastery),
"quizzes_count": len(topic_scores),
"pending_errors": pending_errors,
"status": status,
"last_activity": last_activity,
"synthesis": latest_revision.synthesis if latest_revision else None,
"study_tips": latest_revision.study_tips if latest_revision else None
})
# Sort by last activity
mastery_list.sort(key=lambda x: x['last_activity'], reverse=True)
return mastery_list
@router.get("/stats/activity")
async def get_activity_stats(
learner_id: Optional[uuid.UUID] = None,
user: User = Depends(current_active_user),
db: AsyncSession = Depends(get_async_session)
):
"""
Returns aggregated activity stats:
- Summary: Total minutes today, this week.
- History: Daily breakdown of activities (Revisions + Quizzes).
"""
from app.modules.quiz.models import Revision
from datetime import timedelta
# helper to format date
def to_date_str(dt):
return dt.strftime("%Y-%m-%d")
# Fetch Revisions
revisions_stmt = select(Revision).where(Revision.learner_id == learner_id).order_by(Revision.created_at.desc())
revisions = (await db.execute(revisions_stmt)).scalars().all()
# Fetch Scores (Quizzes)
scores_stmt = select(Score).where(Score.learner_id == learner_id).order_by(Score.created_at.desc())
scores = (await db.execute(scores_stmt)).scalars().all()
# Fetch Pending Remediation Counts per Revision
from app.modules.quiz.models import RemediationQueue
from sqlalchemy import func
remediation_stmt = select(
RemediationQueue.revision_id,
func.count(RemediationQueue.id)
).where(
RemediationQueue.learner_id == learner_id,
RemediationQueue.status == "PENDING",
RemediationQueue.revision_id.isnot(None)
).group_by(RemediationQueue.revision_id)
remediation_counts_result = await db.execute(remediation_stmt)
remediation_map = {row[0]: row[1] for row in remediation_counts_result.all()}
activities = []
# Assumption:
# - 1 Revision = 5 minutes
# - 1 Quiz (Score) = 3 minutes
TIME_PER_REVISION = 5
TIME_PER_QUIZ = 3
for r in revisions:
pending_errors = remediation_map.get(r.id, 0)
activities.append({
"type": "REVISION",
"id": r.id,
"topic": r.topic,
"subject": r.subject, # Add subject
"created_at": r.created_at,
"minutes": TIME_PER_REVISION,
"details": "Révision de cours",
"pending_errors": pending_errors, # Add pending errors count
"current_series": r.current_series,
"total_series": r.total_series,
"completed_series": r.completed_series,
"status": r.status # Add status
})
for s in scores:
activities.append({
"type": "QUIZ",
"id": s.id,
"revision_id": s.revision_id,
"topic": s.topic,
"created_at": s.created_at,
"minutes": TIME_PER_QUIZ,
"details": f"Quiz ({s.score}/{s.total_questions})"
})
# Sort all by date desc
activities.sort(key=lambda x: x["created_at"], reverse=True)
# Group by Date
history = {}
today_str = to_date_str(datetime.utcnow())
week_ago = datetime.utcnow() - timedelta(days=7)
summary = {
"today_minutes": 0,
"week_minutes": 0,
"total_quizzes": len(scores),
"total_revisions": len(revisions)
}
for act in activities:
date_key = to_date_str(act["created_at"])
if date_key not in history:
history[date_key] = {
"date": date_key,
"total_minutes": 0,
"items": []
}
history[date_key]["items"].append(act)
history[date_key]["total_minutes"] += act["minutes"]
# Summary Aggregation
if date_key == today_str:
summary["today_minutes"] += act["minutes"]
if act["created_at"] >= week_ago:
summary["week_minutes"] += act["minutes"]
return {
"summary": summary,
"history": list(history.values())
}
@router.post("/reset", response_model=QuizResponse)
async def reset_revision(
revision_id: uuid.UUID = Body(..., embed=True),
user: User = Depends(current_active_user),
db: AsyncSession = Depends(get_async_session)
):
"""Resets a revision to Series 1 to allow starting over."""
api_key = await get_effective_api_key(user, db)
if not api_key:
raise HTTPException(status_code=401, detail="No API Key")
from app.modules.quiz.models import Revision
revision = await db.get(Revision, revision_id)
if not revision:
raise HTTPException(status_code=404, detail="Revision not found")
# Reset State
revision.current_series = 1
revision.completed_series = 0
revision.status = "IN_PROGRESS"
revision.progress_state = None
try:
# Generate new quiz for Series 1
data = await generate_quiz(
revision.text_content,
api_key,
series_index=1
)
revision.quiz_data = import_json.dumps(data["quiz"])
revision.updated_at = datetime.utcnow()
db.add(revision)
await db.commit()
await db.refresh(revision)
response_quiz = data["quiz"]
response_quiz["revision_id"] = revision.id
response_quiz["series_info"] = {
"current": 1,
"total": revision.total_series
}
return response_quiz
except Exception as e:
import traceback
print(f"Error in reset_revision: {e}")
raise HTTPException(status_code=500, detail="Failed to reset revision.")
@router.delete("/revision/{revision_id}")
async def delete_revision(
revision_id: uuid.UUID,
user: User = Depends(current_active_user),
db: AsyncSession = Depends(get_async_session)
):
"""Deletes a revision and its related data (Scores, RemediationQueue)."""
from app.modules.quiz.models import Revision, Score, RemediationQueue
revision = await db.get(Revision, revision_id)
if not revision:
raise HTTPException(status_code=404, detail="Revision not found")
# Cascade deletes
stmt_remed = delete(RemediationQueue).where(RemediationQueue.revision_id == revision_id)
await db.execute(stmt_remed)
stmt_score = delete(Score).where(Score.revision_id == revision_id)
await db.execute(stmt_score)
await db.delete(revision)
await db.commit()
return {"status": "success", "deleted_id": str(revision_id)}
+50
View File
@@ -0,0 +1,50 @@
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime
import uuid
class QuizRequest(BaseModel):
text_content: str
title: Optional[str] = None
subject: Optional[str] = None
difficulty: str = "medium"
learner_id: Optional[uuid.UUID] = None # To link revision
synthesis: Optional[str] = None
study_tips: Optional[List[str]] = None
class Question(BaseModel):
id: int
question: str
options: List[str]
correct_answer: int # Index of the correct option (0-3)
explanation: str
class QuizResponse(BaseModel):
topic: str
questions: List[Question]
revision_id: Optional[uuid.UUID] = None
class QuestionResult(BaseModel):
question: str
user_answer: str
correct_answer: str
is_correct: bool
original_content: Optional[str] = None # To trace back to source text
class ScoreCreate(BaseModel):
topic: str
score: int
total_questions: int
learner_id: Optional[uuid.UUID] = None
details: List[QuestionResult] = []
revision_id: Optional[uuid.UUID] = None
class ScoreResponse(BaseModel):
id: uuid.UUID
topic: str
score: int
total_questions: int
created_at: datetime
learner_id: Optional[uuid.UUID] = None
new_badges: List[str] = []
revision_id: Optional[uuid.UUID] = None
+267
View File
@@ -0,0 +1,267 @@
"""Quiz/Flashcard generation service."""
import json
import httpx
from typing import Dict, Any, List
from app.modules.ingest.service import OPENROUTER_API_URL, DEFAULT_MODEL
SYSTEM_PROMPT = """You are an expert French teacher.
Create a multiple-choice quiz (QCM) based on the provided lesson text.
Guidelines:
1. **Language**: French.
2. **Topic**: Extract the main topic/subject of the text.
3. **Target Audience**: Students.
4. **Format**: {num_questions} questions.
5. **Difficulty**: {difficulty}
Output strictly valid JSON with this structure. Do NOT include markdown formatting (like ```json), introduction, or conclusion. Just the raw JSON object.
{{
"topic": "string",
"questions": [
{{
"id": 1,
"question": "The question text",
"options": ["Option A", "Option B", "Option C", "Option D"],
"correct_answer": 0, // Index 0-3
"explanation": "Brief explanation."
}}
]
}}
"""
async def generate_quiz(text_content: str, api_key: str, difficulty: str = "medium", series_index: int = 1) -> Dict[str, Any]:
"""Generates a quiz from text content using LLM."""
# Determine number of questions based on text length
# Smart Quiz Sizing based on "Information Density"
# Heuristic: ~400 characters (approx 60-80 words) usually contain 1 indexable fact.
length = len(text_content)
# 1. Estimate maximal unique questions possible
density_factor = 400
max_unique_questions = max(3, int(length / density_factor))
# 2. Determine session size (Cap at 15 to prevent cognitive overload)
target_questions = min(max_unique_questions, 15)
# 3. Adjust for very short texts to avoid hallucinations/redundancy
if length < 600:
target_questions = 3
elif length < 1200:
target_questions = 5
num_questions = target_questions
# Calculate potential series (Party Mode "Revision Series" concept)
# This is just internal logic for now, could be returned in metadata later
total_series = (int(length / 300) // 15) + 1
print(f"Smart Sizing: Length={length} chars -> {num_questions} questions (Series estimate: {total_series})")
# Series Context Hint
series_hint = ""
if total_series > 1:
series_hint = f"\n\nCONTEXT: This text is long and is split into {total_series} parts. This is Part {series_index}. Please focus your questions on the section of the text corresponding roughly to part {series_index}/{total_series}."
prompt = SYSTEM_PROMPT.format(difficulty=difficulty, num_questions=num_questions) + series_hint
payload = {
"model": DEFAULT_MODEL,
"messages": [
{"role": "system", "content": prompt},
{"role": "user", "content": f"Here is the lesson text:\n\n{text_content}"}
],
"response_format": {"type": "json_object"},
"max_tokens": 4000
}
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"HTTP-Referer": "https://reviflow.app",
"X-Title": "Reviflow"
}
async with httpx.AsyncClient() as client:
response = await client.post(
OPENROUTER_API_URL,
json=payload,
headers=headers,
timeout=60.0
)
if response.status_code != 200:
raise Exception(f"OpenRouter API error ({response.status_code}): {response.text}")
result = response.json()
content = result["choices"][0]["message"]["content"]
usage = result.get("usage", {})
try:
return {
"quiz": json.loads(content),
"usage": usage,
"meta": {"total_series": total_series}
}
except json.JSONDecodeError:
print(f"JSON Parse Error - Raw Content: {content}")
# Robust Fallback strategy
import re
cleaned = content
# 1. Try finding a markdown code block
json_match = re.search(r'```json\s*(.*?)\s*```', content, re.DOTALL)
if json_match:
cleaned = json_match.group(1)
else:
# 2. Try finding the outermost JSON object
# Find first '{'
start = content.find('{')
# Find last '}'
end = content.rfind('}')
if start != -1 and end != -1:
cleaned = content[start:end+1]
try:
return {
"quiz": json.loads(cleaned),
"usage": usage,
"meta": {"total_series": total_series}
}
except json.JSONDecodeError as e:
print(f"Deep Parse Failed: {e}")
raise Exception("Failed to parse AI response as JSON. Content might be truncated or invalid.")
async def generate_remediation_quiz_service(context_items: List[Dict[str, Any]], api_key: str, source_text: str = None) -> Dict[str, Any]:
"""Generates a remediation quiz based on errors."""
# Extract topics from context or questions to guide the LLM
# This prevents it from thinking the topic is "Remediation"
topics_context = list(set([item['context'] or "General" for item in context_items if item['context']]))
joined_topics = ", ".join(topics_context)
errors_desc = "\n".join([
f"- Failed Question: {item['question']}\n Student's Wrong Answer: {item['wrong_answer']}\n Correct Answer: {item['correct_answer']}\n Subject Context: {item['context']}"
for item in context_items
])
context_injection = ""
if source_text:
# Truncate source text if too long to save tokens, but keep enough for context
# 1000 chars is usually enough for key concepts
truncated_text = source_text[:3000]
context_injection = f"\n\nREFERENCE LESSON TEXT:\n{truncated_text}\n\n"
REMEDIATION_SYSTEM_PROMPT = f"""You are an expert tutor in the following subject(s): {joined_topics}.
Your Goal: Create a remedial quiz to help a student understand concepts they failed previously.
Input: A list of specific questions the student got wrong, including their wrong answer and the correct answer.
Task:
1. Read the provided REFERENCE LESSON TEXT (if available) to understand the exact curriculum and context.
2. Identify the underlying concept for EACH failed question.
3. Generate a NEW multiple-choice question to test that SAME concept, but phrased differently or using a different example.
4. You MUST generate exactly one new question for every failed question provided in the input list. If there are 10 errors, generate 10 new questions.
5. The "topic" field in the JSON should be: "{joined_topics} (Remediation)".
6. **CRITICAL**: The questions must be about the SUBJECT MATTER ({joined_topics}). Do NOT generate questions about "remediation", "learning strategies", or "translation".
7. **Language**: output MUST be in French.
8. Provide a clear, helpful "explanation" for the correct answer.
Output strictly valid JSON with this EXACT structure:
{{
"topic": "string",
"questions": [
{{
"id": 1,
"question": "New question text...",
"options": ["Option A", "Option B", "Option C", "Option D"],
"correct_answer": 0,
"explanation": "Clear explanation of why this answer is right."
}}
]
}}
"""
payload = {
"model": DEFAULT_MODEL,
"messages": [
{"role": "system", "content": REMEDIATION_SYSTEM_PROMPT},
{"role": "user", "content": f"{context_injection}The student made the following mistakes (Generate exactly {len(context_items)} questions):\n{errors_desc}"}
],
"response_format": {"type": "json_object"},
"max_tokens": 8000
}
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"HTTP-Referer": "https://reviflow.app",
"X-Title": "Reviflow"
}
async with httpx.AsyncClient() as client:
response = await client.post(
OPENROUTER_API_URL,
json=payload,
headers=headers,
timeout=120.0
)
if response.status_code != 200:
raise Exception(f"OpenRouter API error ({response.status_code}): {response.text}")
result = response.json()
content = result["choices"][0]["message"]["content"]
usage = result.get("usage", {})
# --- Robust JSON Parsing Strategy ---
def try_parse_json(text):
try:
return json.loads(text)
except json.JSONDecodeError:
return None
# 1. Direct parse
parsed = try_parse_json(content)
# 2. Markdown block extraction
if not parsed:
import re
json_match = re.search(r'```json\s*(.*?)\s*```', content, re.DOTALL)
if json_match:
parsed = try_parse_json(json_match.group(1))
# 3. Brute-force substring extraction (find outer {})
if not parsed:
start = content.find('{')
end = content.rfind('}')
if start != -1 and end != -1:
parsed = try_parse_json(content[start:end+1])
# 4. Repair Truncated JSON (Simple Auto-Close)
if not parsed:
# Heuristic: If it looks like it ended abruptly, try adding closures
# Try adding "]}" or "}]}" or just "}"
candidates = [content + "}", content + "]}", content + "}]}", content + '"}]}', content + '"]}}' ]
for candidate in candidates:
# Need to strip potential markdown markers if we are patching the raw content
clean_candidate = candidate
start = clean_candidate.find('{')
if start != -1:
clean_candidate = clean_candidate[start:]
parsed = try_parse_json(clean_candidate)
if parsed:
print("WARN: JSON was truncated but successfully repaired.")
break
if parsed:
return {
"quiz": parsed,
"usage": usage
}
print(f"FAILED JSON CONTENT (First 500 chars): {content[:500]}...")
print(f"FAILED JSON CONTENT (Last 500 chars): {content[-500:]}...")
raise Exception("Failed to parse AI response as JSON (Malformed or Truncated)")