mirror of
https://github.com/R0m1k3/Reviflow.git
synced 2026-10-22 14:38:09 +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,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