File size: 12,521 Bytes
d9bd342
5a45a46
 
 
 
 
 
 
 
 
 
 
 
 
 
d9bd342
5a45a46
 
 
 
 
d9bd342
5a45a46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d9bd342
5a45a46
 
 
 
 
 
b911d8c
5a45a46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d9bd342
5a45a46
425af9c
5a45a46
 
425af9c
 
 
5a45a46
 
425af9c
 
 
 
 
 
5a45a46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
425af9c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5a45a46
425af9c
5a45a46
 
 
 
 
 
 
 
425af9c
 
5a45a46
425af9c
 
 
 
 
 
 
 
 
 
5a45a46
 
d9bd342
5a45a46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d9bd342
 
 
 
 
 
 
 
0de31bd
d9bd342
 
 
 
 
 
 
 
99c7e89
d9bd342
 
 
 
 
 
 
6b842bc
d9bd342
 
 
 
 
 
 
 
 
99c7e89
d9bd342
 
 
99c7e89
d9bd342
 
 
 
20a6be7
 
d9bd342
 
 
 
 
 
99c7e89
d9bd342
 
 
 
20a6be7
99c7e89
d9bd342
 
 
 
 
 
 
 
 
 
 
 
 
470975e
 
 
 
 
 
 
 
 
d9bd342
 
 
 
5a45a46
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
# 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)