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

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