Tutorbot / app.py
LiamKhoaLe's picture
Upd primary endpoint 32B and fallback 7B
425af9c
# Access site: https://binkhoale1812-tutorbot.hf.space
import os
import time
import uvicorn
import tempfile
import psutil
import logging
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from google import genai
from gradio_client import Client, handle_file
# ———————— Logging —————————
logging.basicConfig(level=logging.DEBUG, format="%(asctime)s — %(name)s — %(levelname)s — %(message)s", force=True)
logger = logging.getLogger("tutor-chatbot")
logger.setLevel(logging.DEBUG)
logger.info("🚀 Starting Tutor Chatbot API...")
# —————— Environment ———————
gemini_flash_api_key = os.getenv("FlashAPI")
if not gemini_flash_api_key:
raise ValueError("❌ Missing Gemini Flash API key!")
# —————— System Check ——————
def check_system_resources():
memory = psutil.virtual_memory()
cpu = psutil.cpu_percent(interval=1)
disk = psutil.disk_usage("/")
logger.info(f"🔍 RAM: {memory.percent}%, CPU: {cpu}%, Disk: {disk.percent}%")
check_system_resources()
os.environ["OMP_NUM_THREADS"] = "1"
os.environ["TOKENIZERS_PARALLELISM"] = "false"
# —————— FastAPI Setup —————
app = FastAPI(title="Tutor Chatbot API")
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:5173",
"http://localhost:3000",
"https://ai-tutor-beta-topaz.vercel.app",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# —————— Gemini 2.5 API Call ——————
def gemini_flash_completion(prompt, model="gemini-2.5-flash-preview-04-17", temperature=0.7):
client = genai.Client(api_key=gemini_flash_api_key)
try:
response = client.models.generate_content(model=model, contents=prompt)
return response.text
except Exception as e:
logger.error(f"❌ Gemini error: {e}")
return "Error generating response from Gemini."
# —— Qwen 2.5 VL Client Setup —————
logger.info("[Qwen] Using remote API via Gradio Client")
# Read and reasoning on image data sending over
def qwen_image_summary(image_file: UploadFile, subject: str, level: str) -> str:
from gradio_client import Client, handle_file
import tempfile, os
from fastapi import HTTPException
# Not accepted format
if image_file.content_type not in {"image/png", "image/jpeg", "image/jpg"}:
raise HTTPException(415, "Only PNG or JPEG images are supported")
# Write and save image sending over on cache
with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp:
tmp.write(image_file.file.read())
tmp_path = tmp.name
# Engineered prompting
instruction = f"""
You are an academic tutor.
The student has submitted an image that may contain multiple exam-style questions or study material. Your task is to:
1. Carefully extract **each individual question** from the image (if visible), even if they are numbered (e.g., 1., 2., 3.).
2. If any question contains **multiple-choice options** (e.g., a), b), c), d)), include them exactly as shown.
3. Preserve the original structure and wording as much as possible — DO NOT paraphrase.
4. Do not include commentary, analysis, or summaries — just return the extracted question(s) cleanly.
Format your output as:
1. Question 1 text
a) option A
b) option B
c) option C
d) option D
2. Question 2 text
a) ... (if applicable)
Only include what appears in the image. Be accurate and neat.
"""
# ——— 1️⃣ Primary: 32B Model (Qwen/Qwen2.5-VL-32B-Instruct) ———
try:
logger.info("[Qwen32B] Using /predict ...")
client32 = Client("Qwen/Qwen2.5-VL-32B-Instruct")
# Payload handler
_chatbot_payload = [
(None, instruction.strip()),
(None, {"file": tmp_path})
]
# Call client
result = client32.predict(_chatbot=_chatbot_payload, api_name="/predict")
# Clean result
if isinstance(result, (list, tuple)) and result:
assistant_reply = (result[0] or "").strip()
else:
assistant_reply = str(result).strip()
# Primary success
if assistant_reply:
logger.info("[Qwen32B] ✅ Successfully transcribed.")
os.remove(tmp_path)
return assistant_reply
# Empty return
raise ValueError("Empty result from 32B")
# Fail on primary
except Exception as e_32b:
logger.warning(f"[Qwen32B] ❌ Failed: {e_32b} — falling back to Qwen 7B")
# ——— 2️⃣ Fallback: 7B Model (prithivMLmods/Qwen2.5-VL) ———
try:
logger.info("[Qwen7B] Using /generate_image fallback ...")
client7 = Client("prithivMLmods/Qwen2.5-VL")
# Fallback client calling
result = client7.predict(
model_name="Qwen2.5-VL-7B-Instruct",
text=instruction.strip(),
image=handle_file(tmp_path),
max_new_tokens=1024,
temperature=0.6,
top_p=0.9,
top_k=50,
repetition_penalty=1.2,
api_name="/generate_image"
)
# Clean result
result = (result or "").strip()
os.remove(tmp_path)
# Extract fallback result
if result:
logger.info("[Qwen7B] ✅ Fallback succeeded.")
return result
# Empty return
raise ValueError("Empty result from 7B fallback")
# Fail on both
except Exception as e_7b:
logger.error(f"[Qwen7B] ❌ Fallback also failed: {e_7b}")
raise HTTPException(500, "❌ Both Qwen image models failed to process the image.")
# ————— Unified Chat Endpoint —————
@app.post("/chat")
async def chat_endpoint(
query: str = Form(""),
subject: str = Form("general"),
level: str = Form("secondary"),
lang: str = Form("EN"),
image: UploadFile = File(None)
):
start_time = time.time()
image_context = ""
# Step 1: If image is present, get transcription from Qwen
if image:
logger.info("[Router] 📸 Image uploaded — using Qwen2.5-VL for transcription")
try:
image_context = qwen_image_summary(image, subject, level)
except HTTPException as e:
return JSONResponse(status_code=e.status_code, content={"response": e.detail})
# Step 2: Build prompt for Gemini depending on presence of text and/or image
if query and image_context:
# Case: image + query
prompt = f"""
You are an academic tutor specialized in **{subject}** at **{level}** level.
Below is an image submitted by a student and transcribed by a vision model:
--- BEGIN IMAGE CONTEXT ---
{image_context}
--- END IMAGE CONTEXT ---
The student asked the following:
**Question:** {query}
Respond appropriately using markdown:
- **Bold** key ideas
- *Italic* for reasoning
- Provide examples if useful
**Response Language:** {lang}
"""
elif image_context and not query:
# Case: image only — auto-answer based on content
prompt = f"""
You are an academic tutor specialized in **{subject}** at **{level}** level.
A student submitted an image with no question. Below is the vision model’s transcription:
--- BEGIN IMAGE CONTENT ---
{image_context}
--- END IMAGE CONTENT ---
Based on this image, explain its key ideas and help the student understand it.
Assume it's part of their study material.
Respond using markdown:
- **Bold** key terms
- *Italic* for explanations
- Give brief insights or examples
**Response Language:** {lang}
"""
elif query and not image_context:
# Case: text only
prompt = f"""
You are an academic tutor specialized in **{subject}** at **{level}** level.
**Question:** {query}
Answer clearly using markdown:
- **Bold** key terms
- *Italic* for explanations
- Include examples if helpful
**Response Language:** {lang}
"""
else:
# Nothing was sent
return JSONResponse(content={"response": "❌ Please provide either a query, an image, or both."})
# Step 3: Call Gemini
response_text = gemini_flash_completion(prompt)
end_time = time.time()
response_text += f"\n\n*(Response time: {end_time - start_time:.2f} seconds)*"
return JSONResponse(content={"response": response_text})
# ————— Clsr Pydantic Schema —————
from pydantic import BaseModel, Field, validator
from typing import Optional, List, Literal
# Dynamic cls
class StudyPreferences(BaseModel):
daysPerWeek: int = Field(..., ge=1, le=7)
hoursPerSession: float = Field(..., ge=0.5, le=4)
numberWeekTotal: float = Field(..., ge=1, le=52)
learningStyle: Literal["step-by-step", "conceptual", "visual"]
# Dynamic cls
class ClassroomRequest(BaseModel):
id: str
name: str = Field(..., min_length=2)
role: Literal["tutor", "student"]
subject: str
gradeLevel: str
notice: Optional[str] = None
textbookUrl: Optional[str] = None
syllabusUrl: Optional[str] = None
studyPreferences: StudyPreferences
# —————— Time table creator ——————
import json
from fastapi import Body
@app.post("/api/generate-timetable")
async def create_classroom(payload: ClassroomRequest = Body(...)):
"""
Generate a detailed study timetable based on classroom parameters.
"""
# ---------- Build prompt for Gemini 2.5 ----------
prefs = payload.studyPreferences
prompt = f"""
You are an expert academic coordinator.
Create a **{prefs.numberWeekTotal}-week study timetable** for a classroom with the following settings:
- Subject: {payload.subject}
- Grade level: {payload.gradeLevel}
- Instruction (Optional): {payload.notice}
- Study days per week: {prefs.daysPerWeek}
- Hours per session: {prefs.hoursPerSession}
- Preferred learning style: {prefs.learningStyle}
- Role perspective: {payload.role}
{"- Textbook URL: " + payload.textbookUrl if payload.textbookUrl else "Not Available"}
{"- Syllabus URL: " + payload.syllabusUrl if payload.syllabusUrl else "Not Available"}
Requirements:
1. Divide each week into exactly {prefs.daysPerWeek} sessions (label Day 1 … Day {prefs.daysPerWeek}).
2. For **each session**, return:
- `week` (1-{prefs.numberWeekTotal})
- `day` (1-{prefs.daysPerWeek})
- `durationHours` (fixed: {prefs.hoursPerSession})
- `topic` (max 15 words)
- `activities` (array of 2-3 bullet strings)
- `materials` (array of links/titles; include textbook chapters if URL given, else suggesting external textbook/document for referencing)
- `homework` (concise task ≤ 50 words)
3. **Output pure JSON only** using the schema:
```json
{{
"classroom_id": "<same id as request>",
"timetable": [{{session objects as listed}}]
}}
Do not wrap JSON in markdown fences or commentary.
"""
raw = gemini_flash_completion(prompt).strip()
# ---------- Attempt to parse JSON ----------
try:
timetable_json = json.loads(raw)
except json.JSONDecodeError:
logger.warning("Gemini returned invalid JSON; sending raw text.")
return JSONResponse(content={"classroom_id": payload.id, "timetable_raw": raw})
# Ensure id is echoed (fallback if model forgot)
timetable_json["classroom_id"] = payload.id
return JSONResponse(content=timetable_json)
# —————— Launch Server ———————
if __name__ == "__main__":
logger.info("✅ Launching FastAPI server...")
try:
uvicorn.run(app, host="0.0.0.0", port=7860, log_level="debug")
except Exception as e:
logger.error(f"❌ Server startup failed: {e}")
exit(1)