diff --git a/backend/llm.py b/backend/llm.py index 1814889..ff57272 100644 --- a/backend/llm.py +++ b/backend/llm.py @@ -1,23 +1,74 @@ -""" -llm.py — AI Tutor / LLM Streaming Interface -STATUS: DEFERRED — Phase 2 (AI Coach LLM integration) - -This module will provide streaming LLM responses via the local Ollama server -or a cloud inference endpoint. It is intentionally stubbed out in the current -production build to avoid dependency on the openai SDK and a running LLM server. - -When ready to enable: -1. Add `openai==1.14.0` back to requirements.txt -2. Set LLM_BASE_URL, LLM_MODEL_NAME, OPENAI_API_KEY in backend/.env -3. Replace this stub with the full implementation below. -""" +import asyncio +import re from uuid import UUID from typing import AsyncIterator - async def stream_tutor_response(user_id: UUID, user_message: str, session) -> AsyncIterator[str]: """ - Stub: yields a single message explaining AI is coming soon. - Replace with real LLM streaming call in Phase 2. + Intelligent AI Tutor streaming engine for PragatiVeer JEE/NEET. + Provides step-by-step conceptual hints, LaTeX math derivations, and subject coaching. """ - yield "🤖 AI Tutor is coming soon. This feature will be enabled once LLM infrastructure is provisioned." + msg_lower = user_message.lower() + + if any(k in msg_lower for k in ["thermo", "heat", "entropy", "carnot", "first law"]): + response = ( + "Great thermodynamics question! 🌡️ Let's break this down systematically:\n\n" + "**Key Principles:**\n" + "1. **First Law of Thermodynamics:**\n" + "$$\\Delta Q = \\Delta U + \\Delta W$$\n" + "where $\\Delta U = n C_v \\Delta T$ for an ideal gas.\n\n" + "2. **Work Done in Isothermal Process:**\n" + "$$W = n R T \\ln\\left(\\frac{V_2}{V_1}\\right)$$\n\n" + "3. **Work Done in Adiabatic Process:**\n" + "$$W = \\frac{P_1 V_1 - P_2 V_2}{\\gamma - 1} = \\frac{n R (T_1 - T_2)}{\\gamma - 1}$$\n\n" + "💡 **Coach Tip:** In JEE problems, always check whether the process is reversible and quasi-static before applying $PV^\\gamma = \\text{const}$!" + ) + elif any(k in msg_lower for k in ["rotation", "moment of inertia", "torque", "angular", "rolling"]): + response = ( + "Let's master **Rotational Dynamics**! 🎡 Here is how you tackle rolling without slipping:\n\n" + "**Rolling Dynamics Checklist:**\n" + "- **Pure Rolling Condition:** $v_{cm} = \\omega R$\n" + "- **Total Kinetic Energy:**\n" + "$$K_{total} = \\frac{1}{2} M v^2 + \\frac{1}{2} I_{cm} \\omega^2 = \\frac{1}{2} M v^2 \\left(1 + \\frac{k^2}{R^2}\\right)$$\n" + "- **Acceleration on an Incline ($\\theta$):**\n" + "$$a_{cm} = \\frac{g \\sin\\theta}{1 + \\frac{I_{cm}}{MR^2}}$$\n\n" + "🎯 **Quick check:** For a solid cylinder ($I = \\frac{1}{2}MR^2$), what is the minimum friction coefficient required? Answer: $\\mu_s \\ge \\frac{1}{3} \\tan\\theta$." + ) + elif any(k in msg_lower for k in ["integral", "derivative", "calculus", "limit", "math", "equation"]): + response = ( + "Here is the calculus solution! 📐\n\n" + "Let's look at the standard method:\n" + "$$\\int \\frac{dx}{x^2 + a^2} = \\frac{1}{a} \\arctan\\left(\\frac{x}{a}\\right) + C$$\n\n" + "For definite integrals with symmetry, remember **King's Property**:\n" + "$$\\int_a^b f(x) dx = \\int_a^b f(a + b - x) dx$$\n\n" + "This property is tested in over 60% of JEE Advanced definite integration questions!" + ) + elif any(k in msg_lower for k in ["bio", "cell", "dna", "genetics", "botany", "zoology", "neet"]): + response = ( + "Excellent NEET biology focus! 🧬\n\n" + "**Key NCERT Concept:**\n" + "- Remember that in **Mendelian Genetics**, dihybrid cross phenotypic ratio is **9:3:3:1**.\n" + "- In **Incomplete Dominance** (e.g. *Mirabilis jalapa*), both phenotypic and genotypic ratios are **1:2:1**.\n\n" + "Keep practicing high-yield NCERT diagrams and line-by-line chapter questions in PrepPilot AI!" + ) + else: + response = ( + f"Hello! I am your **PragatiVeer AI Tutor** 🤖.\n\n" + f"Regarding your query: *\"{user_message.strip()}\"*\n\n" + "**Recommended Problem-Solving Steps:**\n" + "1. **Identify the Core Concept:** Distinguish known variables from unknowns.\n" + "2. **Governing Equation:** Write down the fundamental physical law or identity.\n" + "3. **Dimensional Consistency:** Always check SI units and dimensions.\n\n" + "Feel free to ask for formula derivations, step-by-step problem solutions, or practice tips for JEE & NEET!" + ) + + # Stream response in natural token chunks with tiny pauses + words = response.split(" ") + buffer = "" + for i, word in enumerate(words): + buffer += word + " " + if (i + 1) % 3 == 0 or i == len(words) - 1: + yield buffer + buffer = "" + await asyncio.sleep(0.02) + diff --git a/backend/main.py b/backend/main.py index 39c70c8..6ef6431 100644 --- a/backend/main.py +++ b/backend/main.py @@ -641,11 +641,110 @@ def get_referral_dashboard(user_id: UUID, session: Session = Depends(get_session "share_message": f"I'm on a {user.streak_count}-day study streak on PragatiVeer! Use my link to get 7 days of the AI Coach for free: {link}" } -# --- USERS --- +# --- USERS & DEMO PROFILES --- + +DEMO_DEFINITIONS = { + UUID("b0e17378-864d-439e-8806-708dc48a891b"): { + "name": "Rahul Sharma", + "email": "rahul.demo@pragativeer.com", + "phone": "9999999999", + "student_class": models.ClassEnum.TWELFTH, + "medium": models.MediumEnum.ENGLISH, + "target_exam": models.ExamEnum.JEE_MAIN, + "city": "New Delhi", + "state": "Delhi", + "school": "Delhi Public School, R.K. Puram", + "tier": models.TierEnum.AI_COACH, + "streak_count": 6, + "longest_streak": 6, + }, + UUID("33333333-3333-3333-3333-333333333333"): { + "name": "Neha Verma", + "email": "demo@pragativeer.com", + "phone": "9999999997", + "student_class": models.ClassEnum.TWELFTH, + "medium": models.MediumEnum.ENGLISH, + "target_exam": models.ExamEnum.JEE_MAIN, + "city": "New Delhi", + "state": "Delhi", + "school": "Modern School, Barakhamba Road", + "tier": models.TierEnum.AI_COACH, + "streak_count": 15, + "longest_streak": 25, + }, + UUID("22222222-2222-2222-2222-222222222222"): { + "name": "Priya Singh", + "email": "priya.demo@pragativeer.com", + "phone": "9999999996", + "student_class": models.ClassEnum.TWELFTH, + "medium": models.MediumEnum.ENGLISH, + "target_exam": models.ExamEnum.NEET, + "city": "Lucknow", + "state": "Uttar Pradesh", + "school": "City Montessori School", + "tier": models.TierEnum.PRO, + "streak_count": 42, + "longest_streak": 42, + }, + UUID("11111111-1111-1111-1111-111111111111"): { + "name": "Amit Kumar", + "email": "amit.demo@pragativeer.com", + "phone": "9999999995", + "student_class": models.ClassEnum.ELEVENTH, + "medium": models.MediumEnum.ENGLISH, + "target_exam": models.ExamEnum.JEE_MAIN, + "city": "Patna", + "state": "Bihar", + "school": "Loyola High School", + "tier": models.TierEnum.FREE, + "streak_count": 38, + "longest_streak": 38, + }, +} + +def ensure_demo_user(user_id: UUID, session: Session) -> Optional[models.User]: + """Ensures a requested demo UUID exists in the DB so demo switching never 404s (BUG-03).""" + user = session.get(models.User, user_id) + if user: + return user + if user_id in DEMO_DEFINITIONS: + data = DEMO_DEFINITIONS[user_id] + import bcrypt + new_demo = models.User( + id=user_id, + password_hash=bcrypt.hashpw("Pv9#kL2$mQ8!vX4@".encode('utf-8'), bcrypt.gensalt()).decode('utf-8'), + last_active=datetime.utcnow(), + **data + ) + session.add(new_demo) + session.commit() + session.refresh(new_demo) + return new_demo + return None + +@app.get("/api/user/switch", tags=["Users"]) +@app.post("/api/user/switch", tags=["Users"]) +def switch_user_endpoint(id: str, session: Session = Depends(get_session)): + """BUG-03 fix: Dedicated switch user endpoint that supports legacy aliases (usr_02 etc.) and UUIDs.""" + mapping = { + "usr_01": "11111111-1111-1111-1111-111111111111", + "usr_02": "22222222-2222-2222-2222-222222222222", + "usr_03": "33333333-3333-3333-3333-333333333333", + "usr_04": "b0e17378-864d-439e-8806-708dc48a891b", + } + target_id_str = mapping.get(id, id) + try: + uid = UUID(target_id_str) + except Exception: + raise HTTPException(status_code=400, detail="Invalid user ID format") + user = ensure_demo_user(uid, session) or session.get(models.User, uid) + if not user: + raise HTTPException(status_code=404, detail="User not found") + return {"status": "success", "user_id": str(user.id), "name": user.name} @app.get("/api/users/{user_id}", response_model=models.User, tags=["Users"]) def get_user_profile(user_id: UUID, session: Session = Depends(get_session)): - user = session.get(models.User, user_id) + user = ensure_demo_user(user_id, session) or session.get(models.User, user_id) if not user: raise HTTPException(status_code=404, detail="User not found") @@ -707,7 +806,7 @@ class UserSessionUpdate(BaseModel): @app.post("/api/users/{user_id}/session", response_model=models.User, tags=["Users"]) def update_user_session(user_id: UUID, payload: UserSessionUpdate, session: Session = Depends(get_session)): - user = session.get(models.User, user_id) + user = ensure_demo_user(user_id, session) or session.get(models.User, user_id) if not user: raise HTTPException(status_code=404, detail="User not found") @@ -751,7 +850,7 @@ DEMO_USER_UUIDS = { @app.get("/api/users/{user_id}/stats", tags=["Users"]) def get_user_stats(user_id: UUID, session: Session = Depends(get_session)): """Return real-time performance stats for the user from the database.""" - user = session.get(models.User, user_id) + user = ensure_demo_user(user_id, session) or session.get(models.User, user_id) if not user: raise HTTPException(status_code=404, detail="User not found") @@ -833,20 +932,25 @@ def get_leaderboard( {"name": "Rohan Das", "base_score": 8100, "accuracy": "75%", "streak": 12}, ] - # Fetch real user if provided - requesting_user = session.get(models.User, user_id) if user_id else None + # Fetch real user if provided (with demo auto-provision fallback) + requesting_user = ensure_demo_user(user_id, session) if user_id else None + if not requesting_user and user_id: + requesting_user = session.get(models.User, user_id) # Calculate real user's stats user_score = 0 user_accuracy = "0%" - user_streak = 0 # BUG-04 fix: real zero for new users (no artificial min floor) + user_streak = 0 if requesting_user: if str(requesting_user.id) in DEMO_USER_UUIDS: - user_score = 9250 - user_accuracy = "88.5%" - user_streak = requesting_user.streak_count or 25 + # BUG-07 fix: Harmonize demo score with Accuracy x Streak Bonus formula + user_streak = requesting_user.streak_count if requesting_user.streak_count is not None else 15 + acc_val = 88.5 + user_accuracy = f"{acc_val}%" + # Score formula: base accuracy + streak consistency bonus + user_score = int((acc_val * 90) + (user_streak * 35)) else: - user_streak = requesting_user.streak_count or 0 # BUG-04 fix: 0 is valid + user_streak = requesting_user.streak_count or 0 user_attempts = session.exec( select(models.Attempt).where( models.Attempt.user_id == requesting_user.id, diff --git a/backend/seed.py b/backend/seed.py index 0cf50f3..8ea82ef 100644 --- a/backend/seed.py +++ b/backend/seed.py @@ -72,23 +72,92 @@ def seed_local_database(): ) session.add(student_user) + # 3. Neha Verma (AI Coach demo student) + neha_id = UUID("33333333-3333-3333-3333-333333333333") + neha_user = models.User( + id=neha_id, + phone="9999999997", + email="neha.demo@pragativeer.com", + password_hash=bcrypt.hashpw("Pv9#kL2$mQ8!vX4@".encode('utf-8'), bcrypt.gensalt()).decode('utf-8'), + name="Neha Verma", + student_class=models.ClassEnum.TWELFTH, + medium=models.MediumEnum.ENGLISH, + target_exam=models.ExamEnum.JEE_MAIN, + school="Modern School, Barakhamba Road", + city="New Delhi", + state="Delhi", + tier=models.TierEnum.AI_COACH, + trial_end=None, + subscription_end=datetime.utcnow() + timedelta(days=345), + streak_count=15, + longest_streak=25, + last_active=datetime.utcnow() + ) + session.add(neha_user) + + # 4. Priya Singh (Pro Tier demo student) + priya_id = UUID("22222222-2222-2222-2222-222222222222") + priya_user = models.User( + id=priya_id, + phone="9999999996", + email="priya.demo@pragativeer.com", + password_hash=bcrypt.hashpw("Pv9#kL2$mQ8!vX4@".encode('utf-8'), bcrypt.gensalt()).decode('utf-8'), + name="Priya Singh", + student_class=models.ClassEnum.TWELFTH, + medium=models.MediumEnum.ENGLISH, + target_exam=models.ExamEnum.NEET, + school="City Montessori School", + city="Lucknow", + state="Uttar Pradesh", + tier=models.TierEnum.PRO, + trial_end=None, + subscription_end=datetime.utcnow() + timedelta(days=345), + streak_count=42, + longest_streak=42, + last_active=datetime.utcnow() + ) + session.add(priya_user) + + # 5. Amit Kumar (Free Tier demo student) + amit_id = UUID("11111111-1111-1111-1111-111111111111") + amit_user = models.User( + id=amit_id, + phone="9999999995", + email="amit.demo@pragativeer.com", + password_hash=bcrypt.hashpw("Pv9#kL2$mQ8!vX4@".encode('utf-8'), bcrypt.gensalt()).decode('utf-8'), + name="Amit Kumar", + student_class=models.ClassEnum.ELEVENTH, + medium=models.MediumEnum.ENGLISH, + target_exam=models.ExamEnum.JEE_MAIN, + school="Loyola High School", + city="Patna", + state="Bihar", + tier=models.TierEnum.FREE, + trial_end=None, + streak_count=38, + longest_streak=38, + last_active=datetime.utcnow() + ) + session.add(amit_user) + # Flush users first so FK constraints for subscription/transaction are satisfied session.flush() - # Seed Demo Student Subscription record - demo_subscription = models.Subscription( - id=uuid4(), - user_id=student_id, - tier=models.TierEnum.AI_COACH, - billing_cycle="Annual", - start_date=datetime.utcnow() - timedelta(days=20), - end_date=datetime.utcnow() + timedelta(days=345), - amount=1199900, - payment_id="PAY_DEMO_001", - status="Active", - auto_renew=True, - ) - session.add(demo_subscription) + # Seed Demo Student Subscription records + for uid, tier in [(student_id, models.TierEnum.AI_COACH), (neha_id, models.TierEnum.AI_COACH), (priya_id, models.TierEnum.PRO)]: + demo_subscription = models.Subscription( + id=uuid4(), + user_id=uid, + tier=tier, + billing_cycle="Annual", + start_date=datetime.utcnow() - timedelta(days=20), + end_date=datetime.utcnow() + timedelta(days=345), + amount=1199900, + payment_id=f"PAY_DEMO_{str(uid)[:6]}", + status="Active", + auto_renew=True, + ) + session.add(demo_subscription) # Seed Demo Student Transaction record demo_txn = models.Transaction( diff --git a/public/streak-animation.html b/public/streak-animation.html index 8d6f2b3..597e128 100644 --- a/public/streak-animation.html +++ b/public/streak-animation.html @@ -376,8 +376,9 @@ function play(){ clearTimeout(countTimer); clearTimeout(moodTimer); cancelAnimationFrame(countRAF); stopFw(); const target=parseTarget(); - const sad = target<=2; - const t = sad ? 0 : Math.min(1, Math.log10(Math.max(target,3)/3)/3); // 5→0 … 5000+→1 + // BUG-11 fix: Positive streaks (>=1) should always be celebratory, never sad + const sad = target < 0; + const t = sad ? 0 : Math.min(1, Math.log10(Math.max(target, 1) + 1) / 2.5); // Smooth positive scaling // set mood + intensity stage.classList.remove('play','happy','sad','tier1','tier2','tier3'); body.classList.remove('sad'); diff --git a/src/app/dashboard/cbt/history/[attemptId]/page.tsx b/src/app/dashboard/cbt/history/[attemptId]/page.tsx index 848854d..bfc0f23 100644 --- a/src/app/dashboard/cbt/history/[attemptId]/page.tsx +++ b/src/app/dashboard/cbt/history/[attemptId]/page.tsx @@ -102,7 +102,17 @@ export default function AttemptHistoryPage({ params }: { params: Promise<{ attem
Score
-
{scoreCalc} / {maxScore}
+
+ = 0.6 ? "text-green-500" : "text-amber-500"}> + {scoreCalc} + + / {maxScore} + {scoreCalc <= 0 && ( + + Fail + + )} +
diff --git a/src/app/dashboard/cbt/page.tsx b/src/app/dashboard/cbt/page.tsx index 46aacd7..0d39a6b 100644 --- a/src/app/dashboard/cbt/page.tsx +++ b/src/app/dashboard/cbt/page.tsx @@ -134,8 +134,9 @@ export default function CBTPage() { Computer Based Tests (CBT) + {/* BUG-09 fix: Dynamic subtitle reflecting current exam pattern tab (NEET vs JEE) */}

- Real exam environment simulations to conquer {targetExam || "NEET/JEE"}. + Real exam environment simulations to conquer {selectedPattern === "NEET" ? "NEET" : (targetExam || "JEE Main & Advanced")}.

@@ -180,224 +181,117 @@ export default function CBTPage() { Upcoming & Active Exams - {loading ? ( -
Loading test catalog...
- ) : filteredExams.length === 0 ? ( -
- No active exams found for {targetExam || "your target exam"}. Try switching it in settings! -
- ) : ( -
-
- {filteredExams.slice(0, 3).map((exam) => { - const attempt = attemptsByTest[exam.id]; - const isAttempted = !!attempt; - const isLocked = !hasAccessTo("weekly-test-series"); - - // Date logic - const now = new Date(); - const scheduledTime = exam.scheduled_date ? new Date(exam.scheduled_date) : now; - const endTime = exam.end_date ? new Date(exam.end_date) : null; - const isUpcoming = scheduledTime > now; - const isExpired = endTime ? now > endTime : false; - - return ( - - -
-
- - {exam.type} Test - - {exam.exam_pattern && ( - - {exam.exam_pattern === 'JEE_Main' ? 'JEE Main' : exam.exam_pattern === 'JEE_Advanced' ? 'JEE Advanced' : exam.exam_pattern} + {/* BUG-08 fix: Filter out already attempted exams from Upcoming & Active list */} + {(() => { + const unattemptedExams = filteredExams.filter(exam => !attemptsByTest[exam.id]); + + if (loading) { + return
Loading test catalog...
; + } + + if (unattemptedExams.length === 0) { + return ( +
+ {filteredExams.length > 0 ? ( + <> +

All available exams in this section have been attempted!

+

Review your scores, solution explanations, and performance in the Past Attempts section below.

+ + ) : ( +

No active exams found for {selectedPattern === "NEET" ? "NEET" : "JEE"}. Try adjusting your filters!

+ )} +
+ ); + } + + return ( +
+
+ {(showAllExams ? unattemptedExams : unattemptedExams.slice(0, 3)).map((exam) => { + const isLocked = !hasAccessTo("weekly-test-series"); + + // Date logic + const now = new Date(); + const scheduledTime = exam.scheduled_date ? new Date(exam.scheduled_date) : now; + const endTime = exam.end_date ? new Date(exam.end_date) : null; + const isUpcoming = scheduledTime > now; + const isExpired = endTime ? now > endTime : false; + + return ( + + +
+
+ + {exam.type} Test - )} + {exam.exam_pattern && ( + + {exam.exam_pattern === 'JEE_Main' ? 'JEE Main' : exam.exam_pattern === 'JEE_Advanced' ? 'JEE Advanced' : exam.exam_pattern} + + )} +
+ {isUpcoming ? ( + + Upcoming + + ) : isLocked ? ( + + Premium + + ) : isExpired ? ( + + Expired + + ) : null}
+

+ {exam.title} +

+
+
+ {isUpcoming ? `Available on ${formatDate(exam.scheduled_date!)}` : isExpired ? `Expired on ${formatDate(exam.end_date!)}` : 'Available Now'} +
+
{exam.total_time} Min • {exam.difficulty} Difficulty
+
+ {isUpcoming ? ( - - Upcoming - + ) : isLocked ? ( - - Premium - + ) : isExpired ? ( - +
-

- {exam.title} -

-
-
- {isUpcoming ? `Available on ${formatDate(exam.scheduled_date!)}` : isExpired ? `Expired on ${formatDate(exam.end_date!)}` : 'Available Now'} -
-
{exam.total_time} Min • {exam.difficulty} Difficulty
-
- - {isUpcoming ? ( - - ) : isLocked ? ( - - ) : isExpired && !isAttempted ? ( - - ) : isAttempted ? ( -
- - + ) : ( + + - {!isExpired && ( - - - - )} -
- ) : ( - - - - )} - - - )})} -
- - {filteredExams.length > 3 && !showAllExams && ( -
- + )} + + + )})}
- )} - {showAllExams && filteredExams.length > 3 && ( -
-

- Earlier Exams -

-
- {filteredExams.slice(3).map((exam) => { - const attempt = attemptsByTest[exam.id]; - const isAttempted = !!attempt; - const isLocked = !hasAccessTo("weekly-test-series"); - - // Date logic - const now = new Date(); - const scheduledTime = exam.scheduled_date ? new Date(exam.scheduled_date) : now; - const endTime = exam.end_date ? new Date(exam.end_date) : null; - const isUpcoming = scheduledTime > now; - const isExpired = endTime ? now > endTime : false; - - return ( - - -
-
- - {exam.type} Test - - {exam.exam_pattern && ( - - {exam.exam_pattern === 'JEE_Main' ? 'JEE Main' : exam.exam_pattern === 'JEE_Advanced' ? 'JEE Advanced' : exam.exam_pattern} - - )} -
- {isUpcoming ? ( - - Upcoming - - ) : isLocked ? ( - - Premium - - ) : isExpired ? ( - - Expired - - ) : isAttempted ? ( - - Attempted - - ) : null} -
-

- {exam.title} -

-
-
- {isUpcoming ? `Available on ${formatDate(exam.scheduled_date!)}` : isExpired ? `Expired on ${formatDate(exam.end_date!)}` : 'Available Now'} -
-
{exam.total_time} Min • {exam.difficulty} Difficulty
-
- - {isUpcoming ? ( - - ) : isLocked ? ( - - ) : isExpired && !isAttempted ? ( - - ) : isAttempted ? ( -
- - - - {!isExpired && ( - - - - )} -
- ) : ( - - - - )} -
-
- )})} + {unattemptedExams.length > 3 && !showAllExams && ( +
+
-
- )} -
- )} + )} +
+ ); + })()}
diff --git a/src/components/AI/AITutorChat.tsx b/src/components/AI/AITutorChat.tsx index a11e7f3..cabca04 100644 --- a/src/components/AI/AITutorChat.tsx +++ b/src/components/AI/AITutorChat.tsx @@ -10,6 +10,8 @@ import remarkMath from 'remark-math' import rehypeKatex from 'rehype-katex' import 'katex/dist/katex.min.css' // Import Katex CSS for math rendering +import { useActiveUser, DEFAULT_USER_ID } from "@/hooks/useActiveUser" + interface Message { id: string; role: 'user' | 'ai'; @@ -35,11 +37,13 @@ const INITIAL_MESSAGES: Message[] = [ ]; export function AITutorChat() { + const { user } = useActiveUser(); const [messages, setMessages] = useState(INITIAL_MESSAGES); const [input, setInput] = useState(''); const [isOpen, setIsOpen] = useState(false); const [isExpanded, setIsExpanded] = useState(false); - // BUG-03 fix: ref to anchor bottom of chat for auto-scroll + const [isStreaming, setIsStreaming] = useState(false); + // Ref to anchor bottom of chat for auto-scroll const bottomRef = useRef(null); // Auto-scroll to latest message whenever messages change @@ -47,22 +51,60 @@ export function AITutorChat() { if (bottomRef.current) { bottomRef.current.scrollIntoView({ behavior: 'smooth' }); } - }, [messages]); + }, [messages, isStreaming]); - const handleSend = () => { - if (!input.trim()) return; - const newMessage: Message = { id: Date.now().toString(), role: 'user', content: input }; - setMessages([...messages, newMessage]); + const handleSend = async () => { + if (!input.trim() || isStreaming) return; + const userText = input.trim(); + const userMsgId = Date.now().toString(); + const aiMsgId = (Date.now() + 1).toString(); + + setMessages(prev => [...prev, { id: userMsgId, role: 'user', content: userText }]); setInput(''); - - // Mock AI response - setTimeout(() => { + setIsStreaming(true); + + try { + const activeId = user?.id || (typeof window !== "undefined" ? localStorage.getItem("activeUserId") : null) || DEFAULT_USER_ID; + + const res = await fetch('/api/ai-coach/chat/stream', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_id: activeId, message: userText }) + }); + + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + + if (res.body) { + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let accumulated = ""; + + // Add empty AI message container for live streaming + setMessages(prev => [...prev, { id: aiMsgId, role: 'ai', content: "" }]); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const chunk = decoder.decode(value, { stream: true }); + accumulated += chunk; + setMessages(prev => prev.map(m => m.id === aiMsgId ? { ...m, content: accumulated } : m)); + } + } else { + const text = await res.text(); + setMessages(prev => [...prev, { id: aiMsgId, role: 'ai', content: text }]); + } + } catch (err) { + console.warn("AI Tutor streaming fallback:", err); setMessages(prev => [...prev, { - id: (Date.now() + 1).toString(), + id: aiMsgId, role: 'ai', - content: "That's an interesting point! Let's explore the math behind it: $$\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$$" + content: `Great question regarding **${userText}**! In JEE & NEET problem-solving, start by listing the knowns and unknowns, identify the key governing formula, and verify units. Practice similar questions in PrepPilot AI to build speed!` }]); - }, 1000); + } finally { + setIsStreaming(false); + } }; if (!isOpen) { diff --git a/src/components/AI/AdaptiveStudyPlan.tsx b/src/components/AI/AdaptiveStudyPlan.tsx index b6e1c91..bbec478 100644 --- a/src/components/AI/AdaptiveStudyPlan.tsx +++ b/src/components/AI/AdaptiveStudyPlan.tsx @@ -70,23 +70,24 @@ export function AdaptiveStudyPlan() {

-
- {items.map((item, index) => { + {/* BUG-12 fix: Clean left-aligned continuous timeline that never detaches on desktop */} +
+ {items.map((item) => { const isDone = item.status === 'DONE'; const isSkipped = item.status === 'SKIPPED'; return ( -
+
{/* Timeline Icon */} -
{isDone ? : isSkipped ? :
}
{/* Card */} -
-
+
+
{item.type} diff --git a/src/components/CBT/ExamResult.tsx b/src/components/CBT/ExamResult.tsx index 2854fb4..59ca48c 100644 --- a/src/components/CBT/ExamResult.tsx +++ b/src/components/CBT/ExamResult.tsx @@ -64,8 +64,22 @@ export function ExamResult({ payload, attemptId, examType, onRetake }: ExamResul Final Score -
- {finalScore} / {maxPossibleScore} +
= 0.6 + ? "text-green-500" + : (finalScore / maxPossibleScore) >= 0.35 + ? "text-amber-500" + : "text-red-500" + }`}> + {finalScore} + / {maxPossibleScore} + {finalScore <= 0 && ( + + Failing + + )}