mirror of
https://github.com/R0m1k3/Reviflow.git
synced 2026-10-19 05:06:44 +02:00
Sauvegarde de l'état (réparation)
This commit is contained in:
commit
7c04fffe77
434 files changed
+83923
No files matched your search
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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)}
|
||||
@@ -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
|
||||
@@ -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)")
|
||||
Reference in new issue
Block a user