mirror of
https://github.com/R0m1k3/Reviflow.git
synced 2026-10-21 14:08:07 +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,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"]
|
||||
Reference in new issue
Block a user