From 2f82348ebcfae5ad111de2c7b16e53b98672a4bb Mon Sep 17 00:00:00 2001 From: Yubraj977 Date: Fri, 20 Feb 2026 01:24:38 -0500 Subject: [PATCH] pushing the error free code --- Backend/__pycache__/main.cpython-313.pyc | Bin 5333 -> 10975 bytes Backend/__pycache__/main.cpython-314.pyc | Bin 4992 -> 11626 bytes .../app/__pycache__/database.cpython-313.pyc | Bin 1136 -> 1155 bytes .../app/__pycache__/database.cpython-314.pyc | Bin 1108 -> 1106 bytes .../__pycache__/feedback.cpython-313.pyc | Bin 20562 -> 25670 bytes .../__pycache__/feedback.cpython-314.pyc | Bin 19790 -> 27253 bytes .../routes/__pycache__/module.cpython-313.pyc | Bin 18729 -> 26966 bytes .../routes/__pycache__/module.cpython-314.pyc | Bin 0 -> 30955 bytes .../__pycache__/student.cpython-313.pyc | Bin 61097 -> 53042 bytes .../__pycache__/student.cpython-314.pyc | Bin 0 -> 57989 bytes Backend/app/api/routes/feedback.py | 178 +++++- Backend/app/api/routes/module.py | 203 ++++++- Backend/app/api/routes/student.py | 441 +++++---------- .../__pycache__/ai_feedback.cpython-313.pyc | Bin 20001 -> 20000 bytes Backend/app/crud/ai_feedback.py | 4 +- Backend/app/database.py | 14 +- Backend/app/models/__init__.py | 1 + .../__pycache__/__init__.cpython-313.pyc | Bin 1160 -> 1220 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 694 -> 1174 bytes .../__pycache__/ai_feedback.cpython-313.pyc | Bin 2560 -> 2559 bytes .../__pycache__/feedback_job.cpython-313.pyc | Bin 0 -> 2403 bytes .../__pycache__/feedback_job.cpython-314.pyc | Bin 0 -> 2460 bytes Backend/app/models/ai_feedback.py | 2 +- Backend/app/models/feedback_job.py | 51 ++ .../__pycache__/ai_feedback.cpython-313.pyc | Bin 61934 -> 73794 bytes .../__pycache__/ai_feedback.cpython-314.pyc | Bin 66166 -> 78916 bytes .../feedback_worker.cpython-313.pyc | Bin 0 -> 25635 bytes .../feedback_worker.cpython-314.pyc | Bin 0 -> 26777 bytes .../__pycache__/openai_client.cpython-313.pyc | Bin 7115 -> 7110 bytes .../prompt_builder.cpython-313.pyc | Bin 23675 -> 27122 bytes .../__pycache__/rag_retriever.cpython-313.pyc | Bin 9765 -> 12614 bytes Backend/app/services/ai_feedback.py | 502 +++++++++++++---- Backend/app/services/feedback_worker.py | 528 ++++++++++++++++++ Backend/app/services/openai_client.py | 10 +- Backend/app/services/prompt_builder.py | 62 +- Backend/app/services/rag_retriever.py | 89 ++- Backend/main.py | 125 ++++- Frontend/app/dashboard/page.js | 120 ++-- Frontend/app/globals.css | 8 + Frontend/app/layout.js | 7 + Frontend/app/page.js | 6 +- .../app/student/module/[moduleId]/page.js | 186 ++++-- Frontend/app/student/test/[moduleId]/page.js | 21 +- .../components/landing/AnimationWrapper.jsx | 2 +- Frontend/components/landing/BrandSection.jsx | 24 +- .../components/landing/FeaturesSection.jsx | 2 +- Frontend/components/landing/Footer.jsx | 52 +- .../landing/TestimonialsSection.jsx | 7 +- Frontend/lib/auth.js | 237 +++++--- Frontend/next.config.mjs | 66 +-- 50 files changed, 2173 insertions(+), 775 deletions(-) create mode 100644 Backend/app/api/routes/__pycache__/module.cpython-314.pyc create mode 100644 Backend/app/api/routes/__pycache__/student.cpython-314.pyc create mode 100644 Backend/app/models/__pycache__/feedback_job.cpython-313.pyc create mode 100644 Backend/app/models/__pycache__/feedback_job.cpython-314.pyc create mode 100644 Backend/app/models/feedback_job.py create mode 100644 Backend/app/services/__pycache__/feedback_worker.cpython-313.pyc create mode 100644 Backend/app/services/__pycache__/feedback_worker.cpython-314.pyc create mode 100644 Backend/app/services/feedback_worker.py diff --git a/Backend/__pycache__/main.cpython-313.pyc b/Backend/__pycache__/main.cpython-313.pyc index 231010b0748c5553ac53dbcfb9900447b72f25c9..5544d41f125f772c256a6fc9841d85b8992c79a8 100644 GIT binary patch literal 10975 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 qF6NiG+g(e~$!)MK@J>3ll1{x& zqoY2W5u_Ldf;mm0Yz2mBHHa7kLy!iHg`k795o{PMS_j4o=CWi2nt(8u8_8URC=Xb4 z@=wk^jJ%t-aqVZE+{&ZJSTuPHkD9-NMrK}ePH9SJUb;eYNoh)IUP*jvUQvEdPA-sH ztfNp|T2z)=8DErIT#%nvoC*|4%#2S{>kQio~*_EKo?J*&Zo%b4rI6iaq;fShxqI#v+yem)Lmhzzr(_L zflKcyi~f%MT3?2!AB24^VT$WF>(lUtb`r z$PYxwfrtPgQOpD+6cmc2fz&O|qSVBc_{@^j+@e4rKNTd;4I~;E9*E0b*^` z%)F8!ClEJqa)X8}R|Sv>3ix9F&C4~mF!GBr8hmDEV3Pd61Y~_Mnw+iW#r2Vikx>I| FH2_aGfocE% diff --git a/Backend/__pycache__/main.cpython-314.pyc b/Backend/__pycache__/main.cpython-314.pyc index ece161ab4d1621d1e974c597f17a41705a311350..ac0be4a66f6212f13e7cdbe343afdeb68d40b568 100644 GIT binary patch literal 11626 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

EnANtKM^}|^6-)4 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 diff --git a/Backend/app/__pycache__/database.cpython-313.pyc b/Backend/app/__pycache__/database.cpython-313.pyc index 3492d2ad87b02dbe5a8e13f9fa043a58bbd19e19..41521cf1340990b594f6dbbcb94d776767dec798 100644 GIT binary patch delta 268 zcmeys(afp-nU|M~0SKb+OwU}y!octt#DM`mDC6^#iRv-JT*2JIJi#E6H<(Y4HJIO$ zed3fJDGqi9hL<8hl`rK$1jw|Pj39z_vK3=ABirO@j21iv`T05VC7HRY`K2YB&oG)W zGAd4HWWG`_3siWE#VN5kwMY@jT*>emNHP>D0Et^1Ho5sJr8%i~MLIw($jIU}An}2j zk&*E(gUVe7^ZN`2K*ntb&btg6A2}Gf#BZ<&+z=AGAt-u-PvC~2Fc3YEmc1b+egjC# gE8dWi{m9KG#P*S&kBg~+?V}vS1`AFfcp@abSQK%J@8KqI!%VcQ8*dZ!mW-S1_L*YcRhh z+r()-QUZJo3@@31>Rz&f2#{GX89{{HWE;k4M%KwQ7%ev6V>Du9RG2Kte5GCnD0hp+ zDX}=UNCC)P$?zFSG8D-JiCY{tx%nxjIjMF<+CVPIl;UI{@qw9+l8o#VXUt;a zV4rNl7{SOkxtCE+jk6#>KPSF8vnur#Pi|sGe12JKQCd!Z`7Iu>L`i0DYJO?S6 zX^=37O>TZlX-=wLkvfpe2*kw&K;i>4BO~Ko29>)E=Jy#4fQ;J=oOc;CKJqYdiQixm zxFIBVLs0YvpTG@4VIZ2!%ObDA&&S2o!1hs&K|p4K*ap`Fp;!358+dNAi(h7!Y+(7q M!pbOIqy{t^0511YApigX delta 303 zcmcb_afL%&n~#@^0SMeAqcbCz85kaeI55BuWqe*TQ9WELh%=5Wh%1gUh&xU$hzH2# zjbqhk4dOFrnK*A2lK|gjE5-;p=3Bf4`T05V1x2ax1(|v2w|KzZqSWNdL+FvWai|T=#?4iJ0&J( zr{<;TPu|NU!72%Klf>j(OcxA9fvj6BPKiLnK!Ph7K7*WFBmolUu*uC&Da}c>D^dY+ z8G*Pseex}4XI0yl)jZU~A_=4O%C;N<3FYGC^)!5|>BKx~8SfzT`b-VHoA V*~KrjOE$24VPRzyE>Z>>000_MQFj0U diff --git a/Backend/app/api/routes/__pycache__/feedback.cpython-313.pyc b/Backend/app/api/routes/__pycache__/feedback.cpython-313.pyc index 4872180f2d2868f781612d1a0b27e98417748045..28800a6ec238eb80cb94e15e240b0223a3e5ac9d 100644 GIT binary patch delta 9245 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 diff --git a/Backend/app/api/routes/__pycache__/feedback.cpython-314.pyc b/Backend/app/api/routes/__pycache__/feedback.cpython-314.pyc index 53334551b31f0fa6a44672f574030d76dd0f11ce..93e2b8bc4cd7fe8cd00f9dcfc9868c593ef1c98a 100644 GIT binary patch delta 11388 zcmd5i3s76vmG9~4F98ygKuE%R0)s7^pZOS!u>oToFdvf#h}gyu89lIUA<0i7w&`@` zezvvKc7pTV#z~XL?WV2WrnMWlQ!}A?q+tTC5>BWx1H&p z^PZmg#BS1=-I=`u=iSe_=bn4cz2}~Lu73U$`MU)&dvkV{k$}{^@I?6Wk#pHj^0{*x zno}L7q{&N+5R9hN+>w>clJeTl>|{37X*+Y0InvjXv`AlT(h6T)XKqJsGPlE)v`P5- zPJ4$V>FCHy=1FBkXMTq>>6G%u&VmkC(k10ho$d}#(j(=~orN9il0EA>yh(2dousA8 ztWIA?QL@NO93p*0EwT$KS$xRS*CFRuLLYIcq?VLxlBI`=`}XnQ)7$ti(%7o@5ljwa zVXXBkx&08-jF3z&{M+g&xvoaV*z*a-kxxV{5i&E{Muk#MouI{(8ax`%O>bDgiP0X6yhJ~R|x&qt?5^$^gHOy^!rFtUPGN?XrS&} zL%p2Q78Cv=zFJjXrDNFRQ*0u6nG~)4NsbLphNBYPw_~8pll(WR?Wq!|^1y#$D}d9)U7|xpYTuRM z?Ht<@g~hkC>`NNV+|d$N`;tz|>WOUYy9Ug@2R(@~HNtel{q`w>P`?*|OppkYK~<0{ zBD^p_fC>-+GN7taGbD)rpgyi1R%S~CK$psLWK;~rs2L5TWps?bl410Yel{msCX!a2hPJ#|5 z=<&2V=&{&jJi;cSF&s@K*nFqxedL%k==ksE)!qKS`dZx-~{(gWQ=2x%sN< zVwUR9H51h;WygB~yO_BDn5%~jP-T+&mr2I8Lk~B+L=d4OT=tB57QX4`C0TKjU9-165)4BM4bUmoAm z;Nq+5_58I$2WjX3q0sYCzr6v>{R$-9nQKyi?i!qRYY?E=Z$+G1kSL};9}1cOfhJc! zLd5kSnO6{zoL2BUWwhIu`5Vrl@DCd7>z5n)@|3>IUx_6Z|D8g&Jlg~RyR%*9rEepi zEIE2Hh0^8Rx$1c{qc^lX(tOo{fnyf7onHFq7&59CP3&syq4+$H{JoHqC5vU938=fbK?PSJg*^qL2q))GEY1gKkOuz(e(0JC+ z7aL9hGeFNn3>e}XCM#fIvIBa?5HK=10W)I>m?|~#Y{t6UP;K=Qf$V!5sX$hSlTswh z=F&!8ry1E7Pi<00af;q;CIyW5QWtvC<+GWi1* zIK$M+MBy-TE(->exabZ|V$a?5VUw904{4T_f&61C&OaD)YiHSKJOTJW={*icF|f>Sky zUUhUr)JR^HsFA!XXvuh0qB#zp3ZRT0jU7%*=cSob{IN85nR;bhr9MB6pK+ct(@C+B zU_8Nwri)~`q-Ql2jE=DpV0e{Gd@@cu$AQBcj?%IpzY0awNKf0+qp^sW9;vbSog1Kp z-tZCdUf=l>B)?BoC&Q^pR?G<}Mnfy~AZjDAi3yey)#2!POjI9D#G;}mG&LDdaOi1> z8ju}#ibK;SYQ|zy(If}v2ksGYBhZeABcKV+3l$u!WrCxzNt=KhNs!~LSWq7Yw^=j< zqlptKmcuK9b%KquvadLrNCuNr3A`(ea_G1~U%5sz7KtTTP!fz7;?M~Yb-~1`=vX)= zY7!A}L&fYiMR1akL__<@F*c4asHl;|!TF$@-yxcNS|1wSJ;Bg8I?tXo|>o% z9p<(`hO0t=7Mw%t&s75u&A~`20@(6BKr~8v8^x6_QqkB6k%SeE$dVZGfRdz|a6pb; zn@&UG)G{%WruG@}+C-y(SpFeoUxL8npCVSCc2yIL15NQ}B7jyI8ETC@{RLvDs z%@Honj}drH`QRM>fJz9u46PYRRA1s6AO=diDq$SdMUW>ib68mo6n zOBA@D)jz3!&i+l;*Idu{+$w9DD{Gqf?U;A&e51hoWZrF$|CXnA&Qm*Uob&9GN;PvS zPtDa+bDkEdxMj|>8*nfEo$JGZ}4Q23<%>B;90+$wLKD{p<}(5?2tIru9doG%EhvE70#$Wg>Al=L@7q4_Y)Xq~!!1wsoTvKgQAsmUk~Fh_&a?Bj2TGN5 zp31BBbDl=2R59nNNaJ_RMueKKc~AEmF06+2zy*R_6qJo{_&|Y<{3kk6=Q_^seY2(g z2KkEN>A{QbU+=lvc5{9GtZ$*P>BjLH<04)1_3W#4bKZtq-eJKzJUe*(!58<-`5R5b z!Qr{C;Tbb5K6}A}t!!~anP6Z4rqg@Tu_y!jV(FrD{l%k;&eBWfC6l%&Z)WdOHc?pn z{oOy@`@-JY)bxDqUf}sgt>EgLxA)xzO7x|qj916_yet6)81J?`w zu?=YbtXpW=KVNr1s68kg92HWohv)4Nznxe3#N^|X!us0zyt-SKx_L|8Z%?QQd(Hbx ze?>}iI@j^Vz5o4g17Y3z{@Xcuw{lA6a!T$Hq+u_4+noDY*V!(?)ik?*wo7n!+%k9G z&T-x$RA39RvHNVd;FcP%b2of9Y(hcLg1L9mzVUhc6<6w8uB-cBp{}?7t@e8I@61AN z&%D1^*tqXTQpg>+WgeI_5Byu z=_iG)BouzS4huh}jhNrO%hazXUa4=h!RLml%?F>KmG7#6&(BjH3P0Xf3NZh<*o?4h zCqSXH7~otk(wVcjmO$Yb+sOU`^)DLrke^rQ_UBRad1}nN%Lg}63wjDr7fgE0+ew6u zR^wm^b`CpFgiJShSVCX?9;YuxNH(C|fjpFG3toZ8hG}>CIZOs2Z*DzY=O7^O<5l$#0~F zTR6O{iKfg^6%KI&P;*%&hY~TS;wQOc_$0uQA&MksISeOC;1d5UYQ2gpW~zn3drN4`<1T z;KGPbLEx2(B61ADaReNK1cK85L_;_cg8(5!y`-VJPhiU@38I|fK84v&Blv3spF!|h z1ZMz%vVxe$>2s6(!`U8kJ^%4+x8^aZ_~x}|vT2Ge<$r9ARH4WuK4sj)Q2cW;`W1g5 zw}dRcb~^VZH5uY}Z&${e6nxJd+X z4V$)Nb9k*l!kr4f6-a2`4ZIq+qEA#zej?&W3KRq zb4caGgzO~wKe;x7lsCFRuQ!FT3yWa<+RMdd)H+nEn61Rj(GdpkRFWO~dVJ1?1`W3f zbA1~;>rgS01Vml>79uk)Et>QEqZM={G5o2ZW5^CAy+EJ;+!2w>H-2a}NP}0xuek8Q|Euk^F>O2zL1QIYy!Q|S$3{b70OG6G^rwN`Y=OtngqVK0wz9Q#HGgzgwo>$ zX5O~gmNb|FFE!ihT+&H-Jz=oCYrrg53Vp}a{F@ba>v%SjfpDzl1wXT;I4c`jhS-u0poPyV%&TIoOs-UEtEN^C9H9E} zQEE*JsQF)46>d}a-6NJ4ATg{JQ1{&KsM|CD(1rj#!pV?Aw%ae4GFX_c8YXl`}2T2yJoKOW3H%bG# z6rx^YSQ#0Jjbs9S76=~gIZ6=VdJ)`9!+wRGp(g$l20QPnmgxB0114a}b1!zGzA#`$ zY#Orb0$GfAFRmDRMSKochKC4b@y8u@XAjBvpt)#8a}G8?UYl=%o^o@wnWwio_{!?_ zRIyB)|8boq7Zz+ur?aCVo(&?&_7O6dxg+IFsq$t94aUAYe4^k#-eBfGX|VARZ_~r` zgHhYA>H(&Z3EomuLTyMBezRWPu~OEIKads0qe4o|CMcorA*B(RMKrS>MkpwuL6Q=z zq8%7~)&4ma$ew3p7IK;1ZLp!+$q*bM5WL(R&w0EykKb9Vqc+8JPXNk zUm)^Ao^#kN%sxDkLT{$|-3k__nW~ocuLVA9WS!_CTMKjD%Acd{{2%MHOz6Z(DZL*y zHsq(5lp)86`U}AZhNXRKN^i$VkI)m^X!R@hHz zJHfg&6iS0ksBsX8_Lcz_Ef6(r5z-5WRs|2OKNPm>5E9 zY1+1IDfp`m-jc@q?Dg#{Rf?GFnwEPqJD5$(PNtb@ft}h~LBJV^2TN+#3SveD9k^^; zNesH|?kw(DAGhEJvJiV97#z0-EO@pAtO0U`d@|=tKm|YdXs_4G67dpUus3mfX-4+Q z5|NGw`7`oWTRZEgP$(+1vh|E zH8O1S+IP0ESEcZsbgBlx(zEA3P48Hd=v{-|T{P1^!0ZS3;9z_IK>O}h7*lw*O8t7I zA3{LL)l^3J%RhjS3$5}mAR1}zX&eX**jvwho|f_HXpFDeX>^64mir2VaRkpIxQO7Z zJhO9`>C2dX62aH_XLs(YLN93PS^iOa55_;~X|MeFPs`pTyr)A@IC%tK>eD~J;NRX^ zmt`zdiuAOPU)l+Vr#UONT_Sb<3v}BNcI+6qg)|FS%`_#6kQ^?r3`dpdWahU36%`dz zsA6E{8Tugi9Au{rhv-(xhoz@;8#9=r?8$HlJ!ALKMsOGK!ScG9Hikb0&Ax5mqE5M zh&?CST6nVCe~H($?aBQnj4m3L06$FfGJmMemy)g; zs6S4+hWhBNZwet>OSXoH8I6_@h!QZ*=YG7NDUBqCk+@!kY)BJAO* z3DJWD6D;>g7~YX8GNA%lX55Q_>CgSZtpm-V9AS_q{aq}66~Pq%@U9_kr-W#g6`z(X zJeA-Sg}0jjfZctX_+2%jX?R<0{A|}Jx`f>J1@!~B<-$z)MZ<-UUD|y0=+)ut?i)il zIt5E$K|OS>y?s5o{*IZ@n6S^x<|`9Vk6qmGbYymmP`E?bF)UaPEvQG{Fy->mJ=;?m z(mf06eYf-Ji}{y)xAM!*n$A$SZT?F|SHhRW^R|XF`rG>K$4qBUGvgQfFSK8@K38+` z#NRYsthj2JEq&SlqJP#c><$P+qr${7Vd$6;hzOCXy96<)I;0XF`76ksARkoyR{ku( zl}mL*1)oQH$uNGysuL=}8KK50D4kTbP*6Il>ZRV5KTsK<4#=egh!UZWZDG1KN#fjiL5#X63>hR?d*IzwW;W^2{PaMA-I_Y*{vB1?GCN-+)5xoT6c+~`jJmt8*T zUaz`3kDZB+!B`w>8dUVMfFW`bYds>8Z^OXArjpHmnf1X-yqs= z678=Om9G;;uM_Uq3CC+h_G^SmhHnrX{+%d)9sg0`@G=)e>P1_)D1J7$1?Z5e))AAp#qLwZU2V9HDxpyQB+F#PCn&R$7& z9xcPp==kjJ+1>NnbHDZVi^A_-5UMs*Ic)^mVfV@8p5bS!8U)j`UAt#h+Z2(FVSC7~ z+CvW2;U~~Hg`J@)wTkoRaCOL~x;SqM*M!`voAcJNCseD}hP@$+iRC)~Eq4eLC&tZOT;WA6s6@}$&8WUpK&*AE(W zLvUnDkQ?CFI4G9JZZ<@en_%PSTQ+W&r4ADGv7(`GfA!vI>~w-=^6B`|Xzp|_C}`#* zN-meoq=A)=9SiS295iT_cvMx?AX8m^HgU&{q^wPpZOYc!pEP@%AD z>Gz$}f=8=KC~1X8RnTWTr$*I$j`goyC}_qrd4(=eCmv>+O{P?Z(hlqxXOlFiQX}xR z8c8RPIxG5sAyG{P3AzR+L@rC=|!>=h-OXZGP4;vA64lt9K4&5K0yWQU5@g4_d~Hp@YF_kOG&x;SX=VZJrDHJXUHV2>KoorJ z=j&6Oytodv*LP?DE-27O_>;p_Zl|~#MMrXSl*t>`z*1jJ_)=y|ZtRp$K>I2yzuK(|fSX6$>MG z2+4{z0cn*BQFA&9Bqsq`yEYJ@P3+Ol9meC(J+k!D=2r!9;8cIIYXT+zm&YF`?Bo7* zf^VsQ;GD(Yic9=R0!uIN+$eTp+_kElnvW}K2slq{J?X6TnRq^>K-Ag4cW>RNndc!S z(S)+tD1-MG0?BkzO~QqX2i8SZRhiGK0eS>?Mi*)3tdfo=(+S-$o_NPtX3wz#T!d&c zrNlKe-qB26y(t!$rkW|0NhB1inIDYOH15IO%D0u0X~=RnN*UoYW#}Elg^|*#quFdv zjOOD#x*>ELMrnaZVh#mrqEJB+>3K9fmPyYh6Fe@Oxnz!hT|6k-Si0cgjWlE_EGXZL zlCOpziYZyV|3UkmLdnn8OrVGnTuNi?m7zheCMY>O?2aKWHHh@5Lw?hjVe~Pw)UkJ? z#GF#EAM%rEhJ+HsD}_I7Hu#C~yLOa4?CWJuNeu`u|EdMLJq@smfRpIgJ8PK>E&wA zvWVRe>((qAk@>_#YHF9woVAdunq@0zZN%!jYDbp(V3!zP&2qySfT_~8A3Jb=_yP-I z5)!gTKRJn)gtu`sbBue&3=!fWvKTP{l_H{p^qI;ZH2w`XhNX}(Yx0wbsf|RWlUSU2 zi;%M1KT&o>ZhRW`id$&Fp4qBb4q+AuTu>Wjn^2si_h7Y*=#MP%o zr13^}b%RGRvM+^OfJUvgmWW|1qW^Gmvdu`OgX4*aM8QEI)$Col&N2%h5a_nDPsiHIs<}b6z-rY-KR{VX z2dnur#M^EXzre*YS=w1(tdZr%Jq&t{9c6dIF1#h~upOHkaqS!(ia5u&m!wwxnFT-y z+2|ZcaBxeTU3fddA-`>Ai`)+SAMm6P@B(Jr7KvVOJ?V_KZpr*tUtC-B!z%C!BV;dwQ$nX+2LX z*LI65V#k2SA(g=)w+Fso46YooUN;fX+Mt`%2W)kpxf~G49cLvdPm2ixMtm9tC6VI;aSFV@LT_XszauVLvBltn9P1w$%Y1lZY$ z4(1=T!W%>Gm0RUa@@BaYYGi*8=>-h^+_-rW>U4x9cZJ(S9a(tH zM1=7$mK(n-VpwHcr9?)=h+Q5WG_rFCy9!%BWA|;VcQ2{7{WD~DK)LI?)12H7C+%D{ zV=Ko(-ldOjLAzGWlJAyx^Fp_W7ri0<8_;Wp&*A=P7t}Qy$1hAUdD;6rq@|CJSq-5o z*dmYBZu!OM9}Voyr%neDV=e#%44*@k1GpogBZXw@ZnG@VlboWyg7 z*>CS35z?&b#2ypI9>Dsk6Mr+(S>V~BlN&9l{hz<}Sb$ATwwY-XMIK-yleCaW^u7Gz z3ugn|Z~#<+GJ4@gE!G5dNEYCx0*Ju}mahVN%EHqt03s;Suiya)EOhAbByjl_AgIDB z|0UCfGE77VF4|TOS!|M(^26yioz_?6*5!gdfunQDTwp%B5KyD1l|U2>co2et&@I=a zIn|6BO_jiFPKRwNP-W5E8_g!!Q&YR#r=hFaR!*by?47B$6CCDeVUAKHDiRJ8IZDi7 z7cLy3c_bXPpFxg45e_1Evllcmno8*}iw>5VZYn$uGMWj1D1c%8>3|4H*APLiW`Y;W zd{U)h9IiYFN&q*m%;XcAm{QVO9fDafrUxFus*>v|WgmSCH?c(HaSrK%HF`uI!QDW$ zwo-6-R&iUoaSJ#dL$b(fPWc`DwZUIZ^l>(H%3Hu)b2UKLVk?7F{L7znS|h(3(_rkh zo;fRU_?ximAGn;D5UO1tAjYj9O17^adiYS$J@`xOD_?wl{hRl{IZ`}6Q;a=OoH<=| zr7lYI*GK9o#!&-tv|P4SJ#Bx|UaZ@3(XxxZHoLA+ba%fL zEB5Vst7m_)d$>3pDZ1{xC>_UR_Y2EFy?>Ni{vfqnvUFbZv|M(3pGmTwORoA$p3Y@E zF}8j}Oh(fuE@HInmh}EiEDj96wdp{y_u%V`MVEY0Is)5R-OmhMveXp4tLKXc&xl12 zWue5F388JUgMB;Ev9~IBCKXM^=9Kw`9(cHOgsn;WJb-Zo;F=jAMq>wIuah>r;+1tTZ|udv-5MUf`|QLuC2iHoG0-ZPT_M>ld>83 zF9W_eC`RrA;ow`3vSp`U?(U{PfGPNfLQsJ&i-I7$M@HW!qgP1#72^F9sd|re{e}3i zknSs_|As*l3|9$|>oy_`eqeQft@*6!l9i>CjqHtNL9no)2mD?R^{PNh3hMta D8R1?2 diff --git a/Backend/app/api/routes/__pycache__/module.cpython-313.pyc b/Backend/app/api/routes/__pycache__/module.cpython-313.pyc index 01b97d12bfab69757f76b1023f289e2606a26b8d..00d47870851554d791791d9e0f71c2adf9f43f2b 100644 GIT binary patch delta 7927 zcmbt2TW}lKb$9VxfFRx=34jCvz9EtzDTp!Ap<3XJ?8L&rx}dp0Y#uo>)f4Q0`>gJCub+@KlXKjJWQ{}_ z;rzKc!$%_%%*1*2whxY-Z%FldUC~Wa>kOOVV-v!>TS|n0mk!< zACzKi(#VV`f+ozUQ_ZQzHW#XpAC0M$Y3g8`&%0 z!;q2F%dla%WGHFkNO@u$E*&y+1{p3HwhUQ0zg1a7l~QKPvRB0tv?|=DV7H!iW9`Z$yi$J2}9W83Ei z7|xWHIe9-x&XZz6`P#ua3i;(tI@%EDq^wYOZ1A;*G?W7>x~mA6QRP%c5q47^%3Fjh zsVd4>fZMbjtmewv5m!FuQRECip+H;D8Z;JGX5pensef|1IxAqWd+bStMpY|`=0HcP z5#l^_1#N%?^-%u31TMGXr)1ed8rn^J@fCMGfKUmP2hgUHb~w;s;)n)zR?$^+V06yM zRdaqWz}0ZITpe9|?jShM8Q!`FfJ5O!*D7cG5$%HWgRr}vuBQUHnA5d_YM-jR8%lf$ zLPDukb zZqgqi79CIuEmI5J0*Z}kFYWFD{tT#qt*vmsUn#osVb6ZRy+;-VfGe3Y!6%P$1f=>>FdhjB5pV zDs(ta%Qe!rO^!5nkd?Nz!c!(T(M^=L`zqK38lmcAO|{Cf2!qF ztDtFvRtUt0rUB+w;J8j!SZqqTeIn46P;;kq4G;OCF=^$-+Sg6o0 zx|t3^!fNRqRA)#{w^6%7X0Cm!nB3n~7BsC>G%cC|x+q1{M>m+Q4{I=~4jf%L`ef6D zuxY>$U{~1?9kTH{z|v4Bxt6WwmTobiI$j0@lfMR z^I-c*x7VQnYC%fEM4?1YLJU-cV<1U&Bdzb`KiD5K(gIM1MvsmN{un1DSSIR^Px?oiJN%dCSUv_se2y^7veA%HBDy72 zLXb3AfDsXSgX=QqROV7?@}I=kOxk&W_eyu*wCK+t}Ijx?LTuj{ycQNpu@G)$_W?I$HMt zW*^4j9$;R-r=zu_9Z)AQDvAdv+Mp%Y`HH~*15EgT!~;Hr$9$8LVIn>=JIyB8Xiz0- z6Y&HyT_C>?u6U?-*KeV}$1qK3cd2gcyw2stOI?2(z z$mB~*i%-7PohDw|zoAA3W8PPt(Z6zGrDNsEH@ih&*L9WX>rURoq?d>rj^^JAP%_Xr zOIwK)eBUs%61Q=^xcxuynOD{0IMYMuvcv2Z3|tvuAQiA9@3Syg1}Zn&fJRAv8>a|t z(rqt7aOwMzQbM8I4&yBkeCohHBYYOHK^2e1l4Az~iQon7&!V-+-f3p$T$I@_HU1%E z6&K=4Ea0eKK$(tAL`>Fpq3N=5Vo*%(51-#Ffazd4^9&7M`7e!V^)@k_kg_Pz5Ci@SouWWnKjnW11kFQ8GdeE3g(MA||O}DwPz2UgoUk zlksPG0_x3{Bl-Wvs%`nt2>vTNj{g*&rM`qOC+6~0F-38bLyphJAsx?6v;2SHMRuh? zY~?!tYfK<6O2lPJSCE4L4OB-Y=M~&>z=ai{^@FS8kNp&`7S}^dBwq{q{R+Oq3sCLh z{wPDvs-@=iiS&GW`s}K;dEw|M2H#CvAZM!=ZS|R0*4DnL`=rEnHF53A_pYR#%bZ#n z{IhYfyko8FXtwNV-r-I+EbqUkN5<-$!6zDgpBs^O&&Qe;`M=|1P0gp8W>M4pv8EyQ zvdx=Z*CAK=wIj<%UOV~vnOD!Ol;)cDh)sL4ReQ6}?wqqvboS**SB|U}$?D8rk!;ye zt6aK&*O^|N{MPw8<8Bo3quA zWy^Nw%Jz$8`}3qVM|wokla7gGBgj_1jZeA4$N5M;$v>8n37O2_-Sg5OS%V6ZtdIe} zNcv?UAd&$Ys1eB;8K@G;syu1Wk%h$o)QM!>hJ`fg?paY%Z+$U_$<8Lx*|c&z*U~G( zzq1!q(HB&axokH-#v5Lo_!hhFMq0zV50zCcjNG!jGl7-<>o$?>$=f}doh#?AcZlS^ zN5kGs|B8^jenKPi+bFp_T!E{xvQp%TY;TV8It`s}smm!HoX>lOy` zHpjxy%~JbP$9GOI5_yY#`Ke5w=xPux4LNg*Xl}`xLyPLX$Gd1;>io9ljux5A){V&F zS{VM!;sDB%XGHSkqG72!{oKm2>y7Vry_ehp0cf&`C6#%TCua(Xra-2C&D8V@S73?A zJDf}O@}BhE2afg?Vx|5~GUsR)9qoCiYiTMqlnE@4riMOpy6@;wSWug%))Wu{9PvKD{d>PdH|ESU~qn6kj(24^8S@%gb- zDDSDe>Gh?b%X*tX>FP^Y=G<*b(cQM@?szwmcX{&VzI;{ij)kl=-b19J1%Ej3BXhRpfLL~5&3bV4^ivB*L861iD6#4au8iJr^{k$x!H0)w zpk)s~b(Kv&y>eEp>bWk6mHlgF1FMm5$lmo--GN>;smnWRa*m+r2(CGrH`LI*ZXmTs zx2h=vgW|xF{k?Q9UGrgS)&JpivMfi+p{V$heNMCmKG&+vx?kA> zi+$^AY0B6>o3?+*O$uEAr~j+<5fj4xZ& zp7-s@dz$jzz%5@(K2Vbn_J9IzBLucLc&FZB(k%>ru>;EOXVq=F>MpUmYpr_snsw$z z_3q`w(xr6Zon1fQ`;)yt?Rn4k-mZTs7ki#qqo!6bOpDaC*fX8HW)rJ-FKR{W%r9(Z zi+wk({*3L0wf2r0*~@RWbt;?1hQn)QMcOTrwf9QkFi2ICbz%5kL<^G-4Zh!Rgf+;p z@3(i=pzVL&wIY*i0})zZ-d**wd0h?A?>BZK8%Ny38`ccIB7@}bgm1v5_MJY>8I}5j z-R?6U@u|J z2yd6?d3c+LHeb?D$ET(M;D3jA$ju*aV@ zWuYUCL^ya`ibNzmG{P7qU%UJVcrkwEDBRFw?@U!3{~)oS?}i&0{*i!x=mql;#HXlt z?d>-PpVK7QRXYgXy>S8&{w%8g7SBdg^*M0;}rXDgakkt<*BUPaDZMqAG4ytksa>yRNEc>yo#{qEsk|{6NYYC;m*YXE;81;G8Nu3Rgc|KVXUmw{{ovfJ3IgY delta 142 zcmcb1iE-s5M!wIyyj%=G;3gTJIo)9*p9HHB8w11Cjk@}5Ea}#oHk*CeBW$D{Z?UH( z7MCOzWEL?46&JC92v!imHYq}6Gh5OmM%|SRMb;n@OCWKJ!zMRBr8FniuIK=e%Lv59 eoj^?=m>C%v?=#q3-fWUm&BFDONsEyUtP21IF(SPH diff --git a/Backend/app/api/routes/__pycache__/module.cpython-314.pyc b/Backend/app/api/routes/__pycache__/module.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..12a460e72a1ed9ebba567fe67ef01d5b9578a620 GIT binary patch literal 30955 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 literal 0 HcmV?d00001 diff --git a/Backend/app/api/routes/feedback.py b/Backend/app/api/routes/feedback.py index 68915c3..fbc69e4 100644 --- a/Backend/app/api/routes/feedback.py +++ b/Backend/app/api/routes/feedback.py @@ -1,12 +1,18 @@ -from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session +from sqlalchemy import func from uuid import UUID +import asyncio +import json import logging +import time from datetime import datetime, timezone -from app.database import get_db +from app.database import get_db, SessionLocal from app.models.ai_feedback import AIFeedback from app.models.student_answer import StudentAnswer +from app.models.feedback_job import FeedbackJob from app.crud.ai_feedback import ( get_feedback_by_answer, check_and_mark_timeout, @@ -18,6 +24,141 @@ logger = logging.getLogger(__name__) +@router.get("/stream/{module_id}") +async def stream_feedback_progress( + module_id: UUID, + request: Request, + student_id: str = Query(..., description="Student ID"), + attempt: int = Query(1, description="Attempt number", ge=1), +): + """ + SSE endpoint for real-time feedback generation progress. + Pushes events as feedback completes instead of requiring polling. + + Events: + - progress: {ready, total, percentage} — when a new feedback completes + - complete: {ready, total} — when all feedback is done + - heartbeat: {} — keep-alive every 15s + + Max duration: 6 minutes (matches frontend timeout). + """ + + async def event_generator(): + MAX_DURATION = 6 * 60 # 6 minutes + POLL_DB_INTERVAL = 2.0 # seconds between DB checks (light on DB for 500 students) + HEARTBEAT_INTERVAL = 15 # seconds between heartbeats + start_time = time.time() + last_heartbeat = start_time + last_ready_count = -1 + + try: + while True: + # Check if client disconnected + if await request.is_disconnected(): + break + + # Check max duration + elapsed = time.time() - start_time + if elapsed >= MAX_DURATION: + yield f"event: timeout\ndata: {json.dumps({'message': 'Stream timeout after 6 minutes'})}\n\n" + break + + # Single efficient query: count total, completed, and failed in one pass + db = SessionLocal() + try: + from sqlalchemy import case, literal_column + from sqlalchemy.orm import aliased + + # Count total answers + total = ( + db.query(func.count(StudentAnswer.id)) + .filter( + StudentAnswer.student_id == student_id, + StudentAnswer.module_id == module_id, + StudentAnswer.attempt == attempt, + ) + .scalar() + ) or 0 + + # Single query: count completed + failed in one pass + status_counts = dict( + db.query( + AIFeedback.generation_status, + func.count(AIFeedback.id) + ) + .join(StudentAnswer, AIFeedback.answer_id == StudentAnswer.id) + .filter( + StudentAnswer.student_id == student_id, + StudentAnswer.module_id == module_id, + StudentAnswer.attempt == attempt, + ) + .group_by(AIFeedback.generation_status) + .all() + ) + + ready = status_counts.get('completed', 0) + failed = status_counts.get('failed', 0) + status_counts.get('timeout', 0) + + # Check pending jobs + pending_jobs = ( + db.query(func.count(FeedbackJob.id)) + .filter( + FeedbackJob.student_id == student_id, + FeedbackJob.module_id == module_id, + FeedbackJob.attempt == attempt, + FeedbackJob.status.in_(["queued", "processing"]), + ) + .scalar() + ) or 0 + + finally: + db.close() + + percentage = round((ready / total * 100), 1) if total > 0 else 0 + all_complete = (ready + failed) >= total and total > 0 and pending_jobs == 0 + + # Send progress event if count changed + if ready != last_ready_count: + last_ready_count = ready + event_data = { + "ready": ready, + "total": total, + "failed": failed, + "percentage": percentage, + "pending_jobs": pending_jobs, + } + yield f"event: progress\ndata: {json.dumps(event_data)}\n\n" + + # Send complete event and stop + if all_complete: + yield f"event: complete\ndata: {json.dumps({'ready': ready, 'total': total, 'failed': failed})}\n\n" + break + + # Heartbeat to keep connection alive + now = time.time() + if now - last_heartbeat >= HEARTBEAT_INTERVAL: + last_heartbeat = now + yield f"event: heartbeat\ndata: {json.dumps({'elapsed': int(elapsed)})}\n\n" + + await asyncio.sleep(POLL_DB_INTERVAL) + + except asyncio.CancelledError: + logger.info(f"[SSE] Stream cancelled for module {module_id}") + except Exception as e: + logger.error(f"[SSE] Error in stream for module {module_id}: {e}") + yield f"event: error\ndata: {json.dumps({'message': str(e)[:200]})}\n\n" + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", # Disable nginx buffering + }, + ) + + @router.get("/{feedback_id}") def get_ai_feedback_by_id( feedback_id: UUID, @@ -237,7 +378,6 @@ def retry_feedback_generation( @router.post("/retry/module/{module_id}") def retry_all_failed_feedback( module_id: UUID, - background_tasks: BackgroundTasks, student_id: str = Query(..., description="Student ID"), attempt: int = Query(1, description="Attempt number", ge=1), db: Session = Depends(get_db) @@ -319,7 +459,7 @@ def retry_all_failed_feedback( logger.info(f"📝 Including answer {answer.id} - no feedback exists, creating pending record") # Create pending feedback record so background task can work properly from app.crud.ai_feedback import create_pending_feedback - create_pending_feedback(db=db, answer_id=answer.id, timeout_seconds=120) + create_pending_feedback(db=db, answer_id=answer.id, timeout_seconds=45) failed_answer_ids.append(str(answer.id)) elif feedback.generation_status is None: @@ -390,27 +530,23 @@ def retry_all_failed_feedback( "answer_ids": [] } - logger.info(f"🚀 ============ RETRYING {len(failed_answer_ids)} FAILED QUESTIONS ============") - logger.info(f"🚀 Answer IDs to retry: {failed_answer_ids}") - - # Import and run the same background task used for initial submission - from app.api.routes.student import generate_feedback_background + logger.info(f"Retrying {len(failed_answer_ids)} failed questions via job queue") - # Add to FastAPI background tasks - logger.info(f"🎯 Adding background task: generate_feedback_background(student={student_id}, module={module_id}, attempt={attempt}, answer_ids={len(failed_answer_ids)} items)") - background_tasks.add_task( - generate_feedback_background, - student_id=student_id, - module_id=str(module_id), - attempt=attempt, - answer_ids=failed_answer_ids - ) - - logger.info(f"✅ Background task added successfully! Feedback generation will start shortly for {len(failed_answer_ids)} questions") + # Enqueue retry jobs — the worker picks them up automatically + from app.services.feedback_worker import create_feedback_job + for answer_id_str in failed_answer_ids: + create_feedback_job( + db=db, + answer_id=answer_id_str, + student_id=student_id, + module_id=str(module_id), + attempt=attempt, + priority=1, + ) return { "success": True, - "message": f"Regenerating feedback for {len(failed_answer_ids)} question(s). This may take a few moments.", + "message": f"Enqueued {len(failed_answer_ids)} feedback retries. The worker will process them shortly.", "answers_retried": len(failed_answer_ids), "answer_ids": failed_answer_ids, "total_answers": len(answers), diff --git a/Backend/app/api/routes/module.py b/Backend/app/api/routes/module.py index 19e0b0a..acd268b 100644 --- a/Backend/app/api/routes/module.py +++ b/Backend/app/api/routes/module.py @@ -465,4 +465,205 @@ def update_chatbot_instructions( "message": "Chatbot instructions updated successfully", "module_id": str(module_id), "chatbot_instructions": module.chatbot_instructions - } \ No newline at end of file + } + + +# ─── Cached dashboard metrics ──────────────────────────────────────────────── +import time, threading, logging +from sqlalchemy import func + +_dashboard_cache: Dict[str, Dict[str, Any]] = {} +_dashboard_cache_lock = threading.Lock() +_DASHBOARD_CACHE_TTL = 30 # 30 seconds — fresh enough for a dashboard + +_logger = logging.getLogger(__name__) + + +@router.get("/modules/{module_id}/dashboard-metrics") +def get_dashboard_metrics( + module_id: UUID, + teacher_id: str = Query(..., description="Teacher user ID"), + db: Session = Depends(get_db) +): + """ + Single endpoint returning all dashboard data for a module. + Cached for 30s so rapid refreshes don't hammer the DB. + Uses JOINs instead of N+1 queries for speed. + """ + cache_key = f"{module_id}:{teacher_id}" + with _dashboard_cache_lock: + cached = _dashboard_cache.get(cache_key) + if cached and (time.time() - cached["ts"]) < _DASHBOARD_CACHE_TTL: + return cached["data"] + + from app.models.student_answer import StudentAnswer + from app.models.question import Question + from app.models.document import Document + from app.models.ai_feedback import AIFeedback + from datetime import timedelta + from sqlalchemy import case, cast, Date + from sqlalchemy.orm import aliased + + module = get_module_by_id(db, module_id) + if not module: + raise HTTPException(status_code=404, detail="Module not found") + + # ── Core counts (3 fast queries) ── + total_answers = db.query(func.count(StudentAnswer.id)).filter( + StudentAnswer.module_id == module_id + ).scalar() or 0 + + unique_student_count = db.query(func.count(func.distinct(StudentAnswer.student_id))).filter( + StudentAnswer.module_id == module_id + ).scalar() or 0 + + questions_count = db.query(func.count(Question.id)).filter( + Question.module_id == module_id + ).scalar() or 0 + + documents_count = db.query(func.count(Document.id)).filter( + Document.module_id == module_id + ).scalar() or 0 + + # ── Single JOIN query: answers + feedback scores ── + # This replaces 3 separate N+1 loops with one query + rows = db.query( + StudentAnswer.id, + StudentAnswer.student_id, + StudentAnswer.question_id, + StudentAnswer.submitted_at, + StudentAnswer.attempt, + AIFeedback.score, + AIFeedback.generation_status, + ).outerjoin( + AIFeedback, AIFeedback.answer_id == StudentAnswer.id + ).filter( + StudentAnswer.module_id == module_id + ).all() + + # ── Process results in Python (single pass) ── + scored_answers = [] + score_ranges = [ + {"range": "0-20%", "min": 0, "max": 20, "count": 0}, + {"range": "21-40%", "min": 21, "max": 40, "count": 0}, + {"range": "41-60%", "min": 41, "max": 60, "count": 0}, + {"range": "61-80%", "min": 61, "max": 80, "count": 0}, + {"range": "81-100%", "min": 81, "max": 100, "count": 0}, + ] + pending_grades = 0 + question_scores: Dict[str, Dict] = {} + + # Activity chart prep + now = datetime.now(timezone.utc) + day_counts = {} + for i in range(7): + day = (now - timedelta(days=i)).date() + day_counts[day] = 0 + + # Recent activity (track top 10 by submitted_at) + recent_rows = [] + + for row in rows: + answer_id, student_id, question_id, submitted_at, attempt, score, gen_status = row + + # Score distribution + if score is not None: + scored_answers.append(score) + for r in score_ranges: + if r["min"] <= score <= r["max"]: + r["count"] += 1 + break + + # Pending grades + if gen_status != 'completed': + pending_grades += 1 + + # Question-level scores for low performance detection + if score is not None: + qid = str(question_id) + if qid not in question_scores: + question_scores[qid] = {"total": 0, "count": 0} + question_scores[qid]["total"] += score + question_scores[qid]["count"] += 1 + + # Activity chart + if submitted_at: + day = submitted_at.date() + if day in day_counts: + day_counts[day] += 1 + + # Recent activity (collect all, sort later) + recent_rows.append({ + "id": str(answer_id), + "student_id": student_id, + "question_id": str(question_id), + "timestamp": submitted_at.isoformat() if submitted_at else None, + "score": score, + "attempt": attempt, + "_submitted_at": submitted_at, + }) + + avg_score = round(sum(scored_answers) / len(scored_answers)) if scored_answers else 0 + + # ── Completion rate ── + total_possible = unique_student_count * questions_count if questions_count else 0 + completion_rate = round((total_answers / total_possible) * 100) if total_possible > 0 else 0 + + # ── Low performance questions ── + low_perf_questions = sum( + 1 for q in question_scores.values() + if q["count"] > 0 and q["total"] / q["count"] < 60 + ) + + # ── Activity chart (last 7 days) ── + activity_chart = [] + for i in range(6, -1, -1): + day = (now - timedelta(days=i)).date() + activity_chart.append({ + "dateKey": day.isoformat(), + "dayName": day.strftime("%a"), + "count": day_counts.get(day, 0) + }) + + # ── Recent activity (last 10) ── + recent_rows.sort(key=lambda r: r["_submitted_at"] or datetime.min, reverse=True) + recent = [ + {k: v for k, v in r.items() if k != "_submitted_at"} + for r in recent_rows[:10] + ] + + # ── Rubric summary ── + try: + rubric_summary = get_rubric_summary(db, module_id) + except Exception: + rubric_summary = None + + data = { + "module_id": str(module_id), + "access_code": module.access_code, + "total_students": unique_student_count, + "total_questions": questions_count, + "total_documents": documents_count, + "average_score": avg_score, + "completion_rate": completion_rate, + "total_submissions": total_answers, + "ai_feedback_count": len(scored_answers), + "score_distribution": score_ranges, + "activity_chart": activity_chart, + "recent_activity": recent, + "action_items": { + "pending_grades": pending_grades, + "inactive_students": 0, + "low_performance_questions": low_perf_questions, + }, + "rubric_summary": rubric_summary, + } + + with _dashboard_cache_lock: + # Evict old entries + stale = [k for k, v in _dashboard_cache.items() if time.time() - v["ts"] > _DASHBOARD_CACHE_TTL * 10] + for k in stale: + del _dashboard_cache[k] + _dashboard_cache[cache_key] = {"data": data, "ts": time.time()} + + return data \ No newline at end of file diff --git a/Backend/app/api/routes/student.py b/Backend/app/api/routes/student.py index 2aeae74..f4a2b88 100644 --- a/Backend/app/api/routes/student.py +++ b/Backend/app/api/routes/student.py @@ -1,7 +1,7 @@ -from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks +from fastapi import APIRouter, Depends, HTTPException, Query, Request from sqlalchemy.orm import Session from uuid import UUID -from typing import List +from typing import List, Optional import logging from app.schemas.student_answer import StudentAnswerCreate, StudentAnswerOut, StudentAnswerUpdate @@ -22,192 +22,12 @@ from app.models.module import Module from app.crud.document import get_documents_by_module, get_documents_by_module_for_students from app.database import get_db +from app.services.feedback_worker import create_feedback_job router = APIRouter() logger = logging.getLogger(__name__) -# 🎯 Background task to generate feedback asynchronously with PARALLEL processing -def generate_feedback_background(student_id: str, module_id: str, attempt: int, answer_ids: List[str]): - """ - Background task to generate AI feedback for multiple answers IN PARALLEL. - This runs asynchronously and generates feedback for all questions concurrently - to significantly reduce total generation time. - """ - import concurrent.futures - import time - from app.database import SessionLocal - from app.services.ai_feedback import AIFeedbackService - from app.models.student_answer import StudentAnswer - - def generate_single_feedback(answer_id: str): - """Generate feedback for a single answer (runs in thread pool)""" - # Each thread gets its own database session - db = SessionLocal() - try: - # Get the answer - answer = db.query(StudentAnswer).filter(StudentAnswer.id == answer_id).first() - if not answer: - logger.error(f"❌ Answer {answer_id} not found") - # Mark feedback as failed - from app.crud.ai_feedback import mark_feedback_failed - mark_feedback_failed(db, answer_id, "Answer not found", "data_error") - return {"success": False, "answer_id": answer_id, "error": "Answer not found"} - - logger.info(f"🔄 Generating feedback for question {answer.question_id}, answer {answer_id}") - - # Generate feedback - feedback_service = AIFeedbackService() - feedback = feedback_service.generate_instant_feedback( - db=db, - student_answer=answer, - question_id=str(answer.question_id), - module_id=module_id - ) - - logger.info(f"✅ Feedback generated for question {answer.question_id}") - return {"success": True, "answer_id": answer_id, "question_id": str(answer.question_id)} - - except Exception as e: - logger.error(f"❌ Failed to generate feedback for answer {answer_id}: {str(e)}") - import traceback - traceback.print_exc() - - # CRITICAL: Mark feedback as failed so it doesn't stay stuck - try: - from app.crud.ai_feedback import mark_feedback_failed - mark_feedback_failed(db, answer_id, str(e), "generation_error") - except Exception as mark_error: - logger.error(f"❌ Failed to mark feedback as failed: {str(mark_error)}") - - return {"success": False, "answer_id": answer_id, "error": str(e)} - finally: - db.close() - - try: - logger.info(f"🎯 Starting PARALLEL background feedback generation for {len(answer_ids)} answers") - start_time = time.time() - - # Use ThreadPoolExecutor to generate feedback in parallel - # Max workers = min(3, number of questions) to avoid database connection exhaustion - # IMPORTANT: Reduced from 10 to 3 to prevent Supabase connection pool exhaustion - max_workers = min(3, len(answer_ids)) - logger.info(f"🚀 Using {max_workers} parallel threads for feedback generation") - - with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: - # Submit all feedback generation tasks - future_to_answer = { - executor.submit(generate_single_feedback, answer_id): answer_id - for answer_id in answer_ids - } - - # Wait for all tasks to complete - completed = 0 - failed = 0 - for future in concurrent.futures.as_completed(future_to_answer): - answer_id = future_to_answer[future] - try: - result = future.result() - if result["success"]: - completed += 1 - logger.info(f"✅ [{completed}/{len(answer_ids)}] Feedback completed for {result['question_id']}") - else: - failed += 1 - logger.error(f"❌ [{completed + failed}/{len(answer_ids)}] Feedback failed for {answer_id}") - except Exception as exc: - failed += 1 - logger.error(f"❌ Task generated exception for {answer_id}: {exc}") - # Ensure feedback is marked as failed even if future.result() crashed - try: - db_safety = SessionLocal() - from app.crud.ai_feedback import mark_feedback_failed - mark_feedback_failed(db_safety, answer_id, f"Exception: {str(exc)}", "generation_error") - db_safety.close() - except: - pass - - elapsed_time = time.time() - start_time - logger.info(f"✅ PARALLEL feedback generation completed for attempt {attempt}") - logger.info(f"📊 Results: {completed} succeeded, {failed} failed out of {len(answer_ids)} total") - logger.info(f"⏱️ Total time: {elapsed_time:.2f} seconds ({elapsed_time/len(answer_ids):.2f}s per question average)") - - # Calculate total score for this test submission - logger.info(f"🔢 Calculating total score for attempt {attempt}") - db = SessionLocal() - try: - from app.models.ai_feedback import AIFeedback - from app.models.question import Question - from app.models.test_submission import TestSubmission - from uuid import UUID - - # Get all answers for this attempt to get their questions - answers = db.query(StudentAnswer).filter( - StudentAnswer.student_id == student_id, - StudentAnswer.module_id == UUID(module_id), - StudentAnswer.attempt == attempt - ).all() - - total_points_possible = 0.0 - total_points_earned = 0.0 - - for answer in answers: - # Get the question to know points possible - question = db.query(Question).filter(Question.id == answer.question_id).first() - if question: - total_points_possible += question.points - - # Get the feedback for this answer - feedback = db.query(AIFeedback).filter( - AIFeedback.answer_id == answer.id - ).first() - - if feedback and feedback.points_earned is not None: - total_points_earned += feedback.points_earned - - # Calculate percentage - percentage_score = (total_points_earned / total_points_possible * 100) if total_points_possible > 0 else 0 - - # Update the test submission - submission = db.query(TestSubmission).filter( - TestSubmission.student_id == student_id, - TestSubmission.module_id == UUID(module_id), - TestSubmission.attempt == attempt - ).first() - - if submission: - submission.total_points_possible = total_points_possible - submission.total_points_earned = total_points_earned - submission.percentage_score = percentage_score - db.commit() - logger.info(f"✅ Test score updated: {total_points_earned}/{total_points_possible} points ({percentage_score:.1f}%)") - else: - logger.warning(f"⚠️ Test submission not found for attempt {attempt}") - - except Exception as score_error: - logger.error(f"❌ Failed to calculate total score: {str(score_error)}") - import traceback - traceback.print_exc() - finally: - db.close() - - except Exception as e: - logger.error(f"❌ Background feedback generation CRASHED: {str(e)}") - import traceback - traceback.print_exc() - - # CRITICAL: Mark ALL remaining pending/generating feedback as failed - # This prevents UI from being stuck in loading state - try: - db_cleanup = SessionLocal() - from app.crud.ai_feedback import cleanup_stale_feedback - from uuid import UUID - marked = cleanup_stale_feedback(db_cleanup, UUID(module_id), student_id) - logger.error(f"🧹 Emergency cleanup: Marked {marked} feedback rows as failed after crash") - db_cleanup.close() - except Exception as cleanup_error: - logger.error(f"❌ Emergency cleanup also failed: {str(cleanup_error)}") - - # 🔍 Join module with access code @router.post("/join-module", response_model=ModuleOut) def join_module_with_code( @@ -406,19 +226,43 @@ def submit_student_answer( if should_generate_feedback: try: - print(f"🎯 ENDPOINT: should_generate_feedback=True, attempt={answer_data.attempt}, max_attempts={max_attempts}") - print(f"🎯 ENDPOINT: created_answer.id={created_answer.id}, answer_data={created_answer.answer}") + # Build progressive feedback context from previous attempts + previous_feedback_context = None + if answer_data.attempt > 1: + from app.models.student_answer import StudentAnswer + from app.crud.ai_feedback import get_feedback_by_answer + + prev_answers = db.query(StudentAnswer).filter( + StudentAnswer.student_id == answer_data.student_id, + StudentAnswer.question_id == answer_data.question_id, + StudentAnswer.attempt < answer_data.attempt + ).order_by(StudentAnswer.attempt).all() + + if prev_answers: + previous_feedback_context = [] + for prev in prev_answers: + fb = get_feedback_by_answer(db, prev.id) + if fb and fb.generation_status == 'completed' and fb.feedback_data: + previous_feedback_context.append({ + "attempt": prev.attempt, + "ai_feedback": fb.feedback_data.get("explanation", ""), + "score": fb.score, + "student_answer": str(prev.answer) + }) + if previous_feedback_context: + logger.info(f"📚 Built progressive context from {len(previous_feedback_context)} previous attempt(s) for question {answer_data.question_id}") + else: + previous_feedback_context = None # Generate AI feedback feedback_service = AIFeedbackService() - print(f"🎯 ENDPOINT: Calling generate_instant_feedback...") feedback = feedback_service.generate_instant_feedback( db=db, student_answer=created_answer, question_id=str(answer_data.question_id), - module_id=module_id + module_id=module_id, + previous_feedback_context=previous_feedback_context ) - print(f"🎯 ENDPOINT: Feedback generated, checking if saved...") # Return enhanced response with feedback return { @@ -594,16 +438,11 @@ def get_module_feedback( Returns filtered feedback without technical metadata, including teacher grades if available IMPORTANT: Also includes teacher-only grades (where teacher graded but no AI feedback exists) """ - from app.crud.ai_feedback import get_student_module_feedback, cleanup_stale_feedback + from app.crud.ai_feedback import get_student_module_feedback from app.models.student_answer import StudentAnswer from app.models.teacher_grade import TeacherGrade from app.models.question import Question - # Cleanup any stale feedback (silent failures from crashed background tasks) - marked_failed = cleanup_stale_feedback(db, module_id, student_id) - if marked_failed > 0: - logger.info(f"🧹 Marked {marked_failed} stale feedback rows as failed") - # Get all feedback for student feedback_list = get_student_module_feedback(db, student_id, module_id) @@ -859,18 +698,30 @@ def save_student_answer( # 🎯 Submit entire test with feedback generation @router.post("/modules/{module_id}/submit-test") -def submit_test( +async def submit_test( module_id: UUID, + request: Request, student_id: str = Query(..., description="Student ID"), attempt: int = Query(1, description="Attempt number", ge=1), - background_tasks: BackgroundTasks = None, db: Session = Depends(get_db) ): """ Mark a test as submitted for a specific attempt. - This creates a TestSubmission record and triggers ASYNC feedback generation. - Returns immediately - feedback is generated in the background. + Creates a TestSubmission record and inserts FeedbackJob rows into the + persistent job queue. The background worker picks them up automatically. + Accepts optional body: { previous_feedback_context: [...] } for progressive feedback. """ + # Parse optional request body for previous feedback context + previous_feedback_context = None + try: + body = await request.json() + if body and isinstance(body, dict): + previous_feedback_context = body.get("previous_feedback_context") + if previous_feedback_context: + logger.info(f"Received previous_feedback_context with {len(previous_feedback_context)} attempt(s)") + except Exception: + pass + from app.crud.test_submission import ( create_submission, has_submitted_attempt, @@ -925,21 +776,22 @@ def submit_test( questions_count=len(answers) ) - logger.info(f"✅ Test submitted - {len(answers)} questions") + logger.info(f"Test submitted - {len(answers)} questions") - # Trigger async feedback generation ONLY if not final attempt + # Enqueue feedback jobs ONLY if not final attempt if attempt < max_attempts: answer_ids = [str(answer.id) for answer in answers] - logger.info(f"🚀 Triggering background feedback generation for {len(answer_ids)} answers") + logger.info(f"Enqueuing {len(answer_ids)} feedback jobs") - # Add background task - if background_tasks: - background_tasks.add_task( - generate_feedback_background, + for answer in answers: + create_feedback_job( + db=db, + answer_id=answer.id, student_id=student_id, module_id=str(module_id), attempt=attempt, - answer_ids=answer_ids + priority=1, + previous_feedback_context=previous_feedback_context, ) return { @@ -947,10 +799,10 @@ def submit_test( "submission_id": str(submission.id), "attempt": attempt, "questions_submitted": len(answers), - "answer_ids": answer_ids, # NEW: for frontend polling + "answer_ids": answer_ids, "can_retry": True, "max_attempts": max_attempts, - "feedback_status": "generating", # NEW: indicates feedback is being generated + "feedback_status": "generating", "message": "Test submitted! Feedback is being generated in the background." } else: @@ -962,7 +814,7 @@ def submit_test( "questions_submitted": len(answers), "can_retry": False, "max_attempts": max_attempts, - "feedback_status": "none", # No feedback for final attempt + "feedback_status": "none", "message": "Final attempt submitted successfully!" } @@ -1027,10 +879,11 @@ def get_feedback_status( ): """ Check the status of feedback generation for a student's test submission. + Uses the FeedbackJob queue as the source of truth. Returns which questions have feedback ready and which are still pending. - Used for real-time polling in the frontend. """ from app.models.student_answer import StudentAnswer + from app.models.feedback_job import FeedbackJob from app.crud.ai_feedback import get_feedback_by_answer # Get all answers for this attempt @@ -1049,26 +902,84 @@ def get_feedback_status( "all_complete": True } - # Check which answers have feedback COMPLETED (not just existing) + # Build answer_id set for quick lookup + answer_ids = {answer.id for answer in answers} + + # Get all jobs for this student/module/attempt in one query + jobs = db.query(FeedbackJob).filter( + FeedbackJob.student_id == student_id, + FeedbackJob.module_id == module_id, + FeedbackJob.attempt == attempt, + ).all() + jobs_by_answer = {job.answer_id: job for job in jobs} + feedback_status = [] ready_count = 0 + queued_count = 0 for answer in answers: feedback = get_feedback_by_answer(db, answer.id) - # IMPORTANT: Only count as ready if generation_status is "completed" - # Don't count "pending", "generating", "failed", or "timeout" as ready + job = jobs_by_answer.get(answer.id) + is_completed = feedback is not None and feedback.generation_status == 'completed' if is_completed: - ready_count += 1 + # Check if this is a FALLBACK that should be upgraded with real AI + is_fallback = (feedback.feedback_data or {}).get('fallback', False) + if is_fallback: + # Check if there's already an active job for this answer + has_active_job = job and job.status in ('queued', 'processing') + retries_exhausted = job and job.status == 'failed' and job.retry_count >= job.max_retries + + if retries_exhausted: + # All retries failed — accept fallback and stop polling + ready_count += 1 + elif not has_active_job: + # Enqueue an upgrade job + create_feedback_job( + db=db, + answer_id=answer.id, + student_id=student_id, + module_id=str(module_id), + attempt=attempt, + priority=1, + ) + logger.info(f"Auto-upgrading fallback feedback for answer {answer.id}") + # Don't count fallback as ready — keep polling until real AI replaces it + else: + ready_count += 1 + elif feedback and feedback.generation_status in ['failed', 'timeout']: + # If no active job exists, enqueue a retry + has_active_job = job and job.status in ('queued', 'processing') + retries_exhausted = job and job.status == 'failed' and job.retry_count >= job.max_retries + + if retries_exhausted: + # Accept as done — can't retry further + ready_count += 1 + elif not has_active_job: + create_feedback_job( + db=db, + answer_id=answer.id, + student_id=student_id, + module_id=str(module_id), + attempt=attempt, + priority=1, + ) + queued_count += 1 + + # Determine job status for this answer + job_status = job.status if job else None + is_fallback = is_completed and (feedback.feedback_data or {}).get('fallback', False) feedback_status.append({ "question_id": str(answer.question_id), "answer_id": str(answer.id), "has_feedback": feedback is not None, - "is_completed": is_completed, + "is_completed": is_completed and not is_fallback, + "is_fallback": is_fallback, "generation_status": feedback.generation_status if feedback else None, - "feedback_id": str(feedback.id) if feedback else None + "job_status": job_status, + "feedback_id": str(feedback.id) if feedback else None, }) total = len(answers) @@ -1081,7 +992,8 @@ def get_feedback_status( "feedback_pending": pending, "progress_percentage": int((ready_count / total) * 100) if total > 0 else 0, "all_complete": all_complete, - "questions": feedback_status + "auto_retried": queued_count, + "questions": feedback_status, } # 🧹 Cleanup stale feedback (called by frontend after timeout) @@ -1171,33 +1083,24 @@ def regenerate_all_failed_feedback( ): """ Regenerate all failed/timeout feedback for a specific attempt. - This is a bulk retry operation. - - Returns: - Status of retry operations + Inserts new FeedbackJob rows — the worker picks them up automatically. """ - from app.crud.ai_feedback import get_feedback_by_answer from app.models.student_answer import StudentAnswer from app.models.ai_feedback import AIFeedback - from app.services.ai_feedback import AIFeedbackService - logger.info(f"🔄 Regenerate all feedback requested for module {module_id}, student {student_id}, attempt {attempt}") + logger.info(f"Regenerate all feedback requested for module {module_id}, student {student_id}, attempt {attempt}") # Get module to check max_attempts module = get_module_by_id(db, str(module_id)) if not module: raise HTTPException(status_code=404, detail="Module not found") - # Get max attempts from module settings (default to 2) max_attempts = 2 if module.assignment_config: multiple_attempts_config = module.assignment_config.get("features", {}).get("multiple_attempts", {}) max_attempts = multiple_attempts_config.get("max_attempts", 2) - # IMPORTANT: Only regenerate feedback for attempts that should have AI feedback - # Final attempt (attempt >= max_attempts) is for teacher manual grading if attempt >= max_attempts: - logger.warning(f"❌ Cannot regenerate feedback for attempt {attempt} - this is the final attempt reserved for teacher grading (max_attempts={max_attempts})") raise HTTPException( status_code=400, detail=f"Cannot regenerate AI feedback for attempt {attempt}. This is the final attempt reserved for teacher manual grading. AI feedback is only available for attempts 1-{max_attempts - 1}." @@ -1213,11 +1116,27 @@ def regenerate_all_failed_feedback( if not answers: raise HTTPException(status_code=404, detail="No answers found for this attempt") - # Find which ones have failed/timeout feedback + # Find which ones have failed/timeout feedback and enqueue retry jobs failed_answer_ids = [] for answer in answers: feedback = db.query(AIFeedback).filter(AIFeedback.answer_id == answer.id).first() - if feedback and feedback.generation_status in ['failed', 'timeout'] and feedback.can_retry: + if feedback and feedback.generation_status in ['failed', 'timeout']: + # Reset the ai_feedback row so the worker can regenerate + feedback.generation_status = 'pending' + feedback.generation_progress = 0 + feedback.error_message = None + feedback.error_type = None + feedback.completed_at = None + db.commit() + + create_feedback_job( + db=db, + answer_id=answer.id, + student_id=student_id, + module_id=str(module_id), + attempt=attempt, + priority=1, + ) failed_answer_ids.append(str(answer.id)) if not failed_answer_ids: @@ -1229,71 +1148,15 @@ def regenerate_all_failed_feedback( "retried_count": 0 } - logger.info(f"📊 Found {len(failed_answer_ids)} failed feedback to retry") - - # Trigger regeneration for all failed feedback - from app.database import SessionLocal - from app.crud.ai_feedback import reset_feedback_for_retry - import threading - - def regenerate_all(): - """Background thread to regenerate all failed feedback""" - # Create a new database session for this thread - thread_db = SessionLocal() - feedback_service = AIFeedbackService() - - try: - for answer_id_str in failed_answer_ids: - try: - answer_id = UUID(answer_id_str) - logger.info(f"🔄 Retrying feedback for answer {answer_id}") - - # Reset feedback for retry - feedback = reset_feedback_for_retry(thread_db, answer_id) - - if not feedback: - logger.error(f"❌ Failed to reset feedback for answer {answer_id}") - continue - - # Get the student answer - answer = thread_db.query(StudentAnswer).filter(StudentAnswer.id == answer_id).first() - - if not answer: - logger.error(f"❌ Answer {answer_id} not found") - continue - - # Trigger regeneration - result = feedback_service.generate_instant_feedback( - db=thread_db, - student_answer=answer, - question_id=str(answer.question_id), - module_id=str(answer.module_id) - ) - - logger.info(f"✅ Retry queued for answer {answer_id}") - - except Exception as e: - logger.error(f"❌ Error retrying feedback for answer {answer_id_str}: {e}") - continue - - except Exception as e: - logger.error(f"❌ Error in bulk regeneration: {e}") - finally: - thread_db.close() - logger.info(f"🏁 Bulk regeneration completed for {len(failed_answer_ids)} answers") - - # Start background thread - thread = threading.Thread(target=regenerate_all) - thread.daemon = True - thread.start() + logger.info(f"Enqueued {len(failed_answer_ids)} retry jobs") return { "success": True, - "message": f"Started regenerating {len(failed_answer_ids)} failed feedback", + "message": f"Enqueued {len(failed_answer_ids)} feedback retries", "total_answers": len(answers), "failed_count": len(failed_answer_ids), "retried_count": len(failed_answer_ids), - "answer_ids": failed_answer_ids + "answer_ids": failed_answer_ids, } diff --git a/Backend/app/crud/__pycache__/ai_feedback.cpython-313.pyc b/Backend/app/crud/__pycache__/ai_feedback.cpython-313.pyc index 8be75632bf01549a6c586f262c806107aa126fc0..933a4f1ffb4fec998f23a36f3f3381bd80c4f9eb 100644 GIT binary patch delta 52 zcmZ2DhjGCiM&8f7yj%=G(9Sw7({dy4Y$-;c$t$J)GMSieUMW3Sk5PBCyVp-fM!C(7 HJ`WfHtXUAy delta 53 zcmZ25hjHN?M&8f7yj%=G@LM7#(|RNCY$- bool: def create_pending_feedback( db: Session, answer_id: UUID, - timeout_seconds: int = 120 + timeout_seconds: int = 45 ) -> AIFeedback: """ Create a placeholder feedback row BEFORE generation starts. @@ -125,7 +125,7 @@ def create_pending_feedback( Args: db: Database session answer_id: UUID of the student answer - timeout_seconds: Maximum time allowed for generation (default 120s) + timeout_seconds: Maximum time allowed for generation (default 45s) Returns: AIFeedback object with status='pending' and feedback_data=None diff --git a/Backend/app/database.py b/Backend/app/database.py index 9fd82e3..d1360be 100644 --- a/Backend/app/database.py +++ b/Backend/app/database.py @@ -5,15 +5,15 @@ import os from app.core.config import DATABASE_URL -# Add connection pool settings to handle timeouts and stale connections -# IMPORTANT: Reduced pool size to prevent Supabase connection exhaustion -# In Session Mode, Supabase has strict connection limits +# Connection pool for 500 concurrent students + 10 worker threads. +# pool_size=20 keeps connections warm; max_overflow=30 allows bursting to 50. engine = create_engine( DATABASE_URL, - pool_pre_ping=True, # Verify connections before using them - pool_recycle=3600, # Recycle connections after 1 hour - pool_size=3, # Maximum number of connections to keep in pool (reduced from default 5) - max_overflow=5, # Maximum additional connections when pool is full (reduced from default 10) + pool_pre_ping=True, # Verify connections before using them + pool_recycle=1800, # Recycle connections every 30 min (Supabase can drop idle ones) + pool_size=20, # Warm connections (10 worker threads + API request threads) + max_overflow=30, # Burst up to 50 total under load (500 students) + pool_timeout=30, # Fail fast if pool is exhausted (seconds) connect_args={ "connect_timeout": 10, "keepalives": 1, diff --git a/Backend/app/models/__init__.py b/Backend/app/models/__init__.py index fc3ee68..165ec4c 100644 --- a/Backend/app/models/__init__.py +++ b/Backend/app/models/__init__.py @@ -18,6 +18,7 @@ from app.models.chat_message import ChatMessage # ✅ NEW: Chat messages from app.models.teacher_grade import TeacherGrade # ✅ NEW: Teacher manual grades from app.models.feedback_critique import FeedbackCritique # ✅ NEW: Student feedback critiques +from app.models.feedback_job import FeedbackJob # ✅ NEW: Persistent feedback job queue # from app.models.autosave import Autosave # from app.models.attempt_summary import AttemptSummary # from app.models.audio_explanation import AudioExplanation diff --git a/Backend/app/models/__pycache__/__init__.cpython-313.pyc b/Backend/app/models/__pycache__/__init__.cpython-313.pyc index 8bb95676b8abe6bbb8d7cf48994065a3f7c1c95b..1c16babc9ca6f1e4e9a1098d19ba78e760718cb4 100644 GIT binary patch delta 130 zcmeC+Ji^KMnU|M~0SKh#PRrC|o5&}@cxR&eMS)<3U_oyMFU2B71%`AXP2q`u{s?I@ z-r{ykO-)HkOwRVoPx8}L+WedGKBM$4@x+1xz1;ki)SP0yG^m33to)=R;mOyTQ@J#N Z#xnwOvF&6x7Ax+%43_s8^o!JhA^`c%CWQb1 delta 73 zcmX@Y*}=*8nU|M~0SMeAqcg9tPUMqdyf9JyB1^iUrqE;##y`w{nhKl6nC>%97G_D} YQUfYv1ma?=$@MH&9QPP3i&TJI0H^d36aWAK diff --git a/Backend/app/models/__pycache__/__init__.cpython-314.pyc b/Backend/app/models/__pycache__/__init__.cpython-314.pyc index 8654bc614a0f4d316457021eb78eb010874337c2..c2ea7e6cc2dd7b6f5acaf78e9280056621fa04a6 100644 GIT binary patch delta 642 zcmZ9I%TB^T6ov;AN#s&)A{RmMZes#*;er@fL~TuU0mP+EX-BC-i)W@JE(~jzgoK?B zpby}~kW?SS7jR}k(l|*cU;gv=%=ypf@?=Fil%gTTPX29TRe~ca8~7USzueuTMo3L6 zDVi!F#$k0;Nz=68h`Od^Xhv{U%_=#X6D+BDWu2}Ij;RHuNQ;8w>V{IHCBX@`tZdTF z5^5y3k(?TRxI?Lh32nOVo<)e|*m7VRymCypiy0-F)HSh9&unnI-5w(*N=+9A__={e z-?0g|#p@lN)*X9*f#`JBB+RudOo)EZEs+-1jShwj&`t5nImTvNHy-nKu&DLqaxwfT zUOMe-d21{OzK40e->>u>6L(34vPm_v7!|^7Z_|X&zTO-Yhl5c{S^+(%BBQd%grnhzES-yZK5qW4;nd zBKh9~3(teoh7{}Yj(*sUrW@m%B{_7!Fu*=?I(}W+EqmzLLpvTi5m56`)k8-fs(qs> H>@nLfy~WFx delta 160 zcmbQnxs8=in~#@^0SMl4y~~)$Jdsa=v2LPzEn5(mGJ_`fWG=>1E;ir%l+v73KTV;@ z5={5~1QQDi^m6l4Qgezy6i}v!8>oLJ!)K5&w|w+Ni&Kk=^(#x0iW0N*T~ff}HcRgU`%Dn(*IE&xqG BEf4?z diff --git a/Backend/app/models/__pycache__/ai_feedback.cpython-313.pyc b/Backend/app/models/__pycache__/ai_feedback.cpython-313.pyc index 57ea68d0581f72d007d621a98210aafc28c8bf2c..58ecae4b92b807a6f18734f5d9ebac30cc645dad 100644 GIT binary patch delta 49 zcmZn=`7g}-nU|M~0SGEtr)5sw$orp}Ll?-q#bjc-S(Ife3wzRqz|f0gVVgH{_%i|k DRt^qQ delta 50 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 processing -> done / retry / failed + status = Column(String(20), nullable=False, default='queued', server_default='queued') + + # 1=urgent (student waiting), 2=normal, 3=background-upgrade + priority = Column(Integer, nullable=False, default=1, server_default='1') + + # Retry management + retry_count = Column(Integer, nullable=False, default=0, server_default='0') + max_retries = Column(Integer, nullable=False, default=5, server_default='5') + + # Worker lock — prevents double-processing + locked_at = Column(TIMESTAMP(timezone=True), nullable=True) + + # Error tracking + error_message = Column(Text, nullable=True) + + # Optional context for progressive feedback (stored as text, JSON-serialized) + previous_feedback_json = Column(Text, nullable=True) + + # Timestamps + created_at = Column(TIMESTAMP(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)) + completed_at = Column(TIMESTAMP(timezone=True), nullable=True) + + __table_args__ = ( + Index('ix_feedback_jobs_status_priority', 'status', 'priority', 'created_at'), + Index('ix_feedback_jobs_answer_id', 'answer_id'), + Index('ix_feedback_jobs_student_module', 'student_id', 'module_id', 'attempt'), + ) diff --git a/Backend/app/services/__pycache__/ai_feedback.cpython-313.pyc b/Backend/app/services/__pycache__/ai_feedback.cpython-313.pyc index e43051b8180fa9d45630c3511e114285c5366aae..f2cc968143d7a9f52c5dd1e481f8404d5e7e3f9e 100644 GIT binary patch literal 73794 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 diff --git a/Backend/app/services/__pycache__/ai_feedback.cpython-314.pyc b/Backend/app/services/__pycache__/ai_feedback.cpython-314.pyc index 66b8ff94f666f3a3109d38da93e484abb8f366e8..471954de6e09dbd4bd5a9600f8a80772d817333e 100644 GIT binary patch literal 78916 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 diff --git a/Backend/app/services/__pycache__/feedback_worker.cpython-313.pyc b/Backend/app/services/__pycache__/feedback_worker.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..21fc26b5431c428bc617b22ced15cad424fbbe36 GIT binary patch literal 25635 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 Y#4xOb0_+ln;jLVFyW`j7H<{i42lW@KcK`qY literal 0 HcmV?d00001 diff --git a/Backend/app/services/__pycache__/feedback_worker.cpython-314.pyc b/Backend/app/services/__pycache__/feedback_worker.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3ef40464e95ea4932068f17a8bca9987f4834102 GIT binary patch literal 26777 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 delta 565 zcmaixyGsK>5XQ5YJ4s?Lo-vopc`6E`9z-G-4e?oM6d(9teIPDe5)BfKL?#z)Ev!T= z+`mN5(%L4Km5|QT+FsmAIvaP{AK%V?GrPmSWf~dvTUA3oG4!G1vi_`2OC-*hrEw)d zgEYjirJKpltUTbuNe!y99u{K?Q__+ml5o)oQ)m~{sm9{8n;Gd4jq*XixtsXc-L7S{ zYl&Qw8LYRxONVK!*LNckB;KSx< zb{3_Kzy~xHw|-SAHRWQpT7fN$=#Eo!3vT(uDY!0_i*>hDa~w$VJ9ArxcXh`5FioQE oV~H4I9&z|&t=TzLt!~1ouu-rO34vx{?y`DXuap)b&%xUJ1y+7<(f|Me diff --git a/Backend/app/services/__pycache__/prompt_builder.cpython-313.pyc b/Backend/app/services/__pycache__/prompt_builder.cpython-313.pyc index 837077cbc0ed66924ed50319edf9f187673a9429..614255c1a3d6890aebf483b2ae5f5349c8c93f7e 100644 GIT binary patch delta 6881 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 delta 4082 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 diff --git a/Backend/app/services/__pycache__/rag_retriever.cpython-313.pyc b/Backend/app/services/__pycache__/rag_retriever.cpython-313.pyc index 4bbee0889b851659036bb85ea9cc3ece3047d5cf..bc3b5f5274f10a4c51d8fcfc0873d686a00d2f2d 100644 GIT binary patch delta 5968 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 OcaD*+w+jsMjQ;_L|EBc- delta 2989 zcmbVOU2GKB6}~gGvp=(cv;VvHvYxfU@p_H1u}#4x2A2k?jooC2p)qU4UGEPpm|3{9 zreNBbs4wnQsctJ7DJYSuN~Wn)t3324sG?HSAo?;CE4b4HrBWNUQXkepRgxxY&mDVV zp>Ms`oH_TLbI2Ry>0 zTXs%*WY?ruc29DXu1QNj%R|h5QT8;U6Z4i63>M|6?44vA)zoC;kgMM+i(pDEeX_67 z3S7GSEwZ0#v0n~Az~D1_FPsjU`oOVpDmM2`Av1hH>c|wUxLiD&BUV-;bL{w5wlHnD z&&(FJYPq6LRg34UhDWQ;7K&o!l`id*(R2iyNS-+aI=i&Qh#wT?z{W}2Uz zt82vz>G4peUOA^_*y0OzOR5_MOyr1+m{7BQ2g z(=eah2*FAKL=Gqx2oq2Ok0HUpKHU)6wz^j(pjZbsrc(`Ox76iX`u)tM1ORU6ab@jL z*^m;Fy|l6b7A}B8w3HgFhU%ZA{#L_s90CND@OmF6Q8vEe71>a(2V8j?%I||di_$*N z`x|=7Q};%1lsYe=79nJGjJV1^&SZF_8Lf;oR0nn@HDq}F`G};2>6K=w3-#wE%HC@OJ z8J*;_RFr%k@_#i4N)4)lE~4e+@}Zw?djLE-oMH{`WbwRV!^N{jtQG&xfpm`R?kmm3 zB}qD5IW6@`TKQCYCWp(_^OBaY;G#AtkvBr^yD@DbEK!mKVgisb{b+bICG<1F#MI+1 zF-u)P_TqMsq=KX+5@OtBAhK=1;AV=d!B@-GnIeWhLk6c+vB8!Lh7~USqFOKUzqwOAmFk*)0!OfPl}ortiD5;r9FW1 zfxgxgF`Sf#jAm!58m2dl!RL?9swZ(2cr8NaqfIPsBUhpc-&a6FgJ|2KMi)_Skl13E zm=DQfth?aW#qOVI%RArt)>1|H46WGr{3+19u@qf3W$&#o2wAi2`q@7TcjP$VBU zXXag2K+IboJ$#8lV(bB89U&-$9^AT=|7q!JY3b$V%ysS;!*5%c&*}cXE3V;tuHe6G zJeYlY-@`?%AK&+*@SczFw*#;BLdD-xwPny|c^umZi=Dvq{UZXxezhwD~S z7;WZnr8kd;`QL|m9yU09Z7nYFOpttiC51k_;yG!-uV0 z+Te~AtA^!7rL3kLI1JIut})#Cic0_8m^z)qRn1_^_#kO%mzXiKx4qSC_V_MpOv!hy zooU~}GVRxvI<7Ew`l~g!dAvgQvawL^Yz{_e8XDVlEz=`Wa-nl1PA7NV93GfKw!6n* z@*2J2B$y!b=guAM3L>%e7UmGyliuBJF42#m<=DSVVlc|1)8PP1>P@|9V& z3L)M~Z)e)by>!fa3bgMa@>%-Zfbg}h6HF{ikFi*V7p?$#64%n5v}zzR5J`9UZ>FO< zp5kx{-p5Mj`e@JL6mvKgG;C8-g-U*EioDr;3Kg$c*tOEZ-mRCKyhnZa~s%o1UW z0#ny!%H}0t-V}y?_xk^DL-+z{=^ds$0#f4`hFN1d#_}&aVz%BvT_2#EvwI^*Ss3TSXW=Mdutr!J`99wJY{ND6pGetCTmAxpyn`H0r`S8r^&idvs81N ktOq5x)J#v{+A1uxEociJuf6h5AJwkSab%@Owa{Ap7kM9V?*IS* diff --git a/Backend/app/services/ai_feedback.py b/Backend/app/services/ai_feedback.py index df0be1c..99212dc 100644 --- a/Backend/app/services/ai_feedback.py +++ b/Backend/app/services/ai_feedback.py @@ -14,7 +14,8 @@ from app.services.prompt_builder import ( build_mcq_feedback_prompt, build_text_feedback_prompt, - should_include_context + should_include_context, + _build_previous_feedback_section ) from app.crud.ai_feedback import ( create_feedback, @@ -42,7 +43,8 @@ def generate_instant_feedback( db: Session, student_answer: StudentAnswer, question_id: str, - module_id: str + module_id: str, + previous_feedback_context: Optional[List[Dict[str, Any]]] = None ) -> Dict[str, Any]: """ Generate instant AI feedback for student submission with rubric and RAG support @@ -52,6 +54,8 @@ def generate_instant_feedback( student_answer: StudentAnswer object question_id: UUID of the question module_id: UUID of the module (for getting AI model config and rubric) + previous_feedback_context: Optional list of per-question previous attempt feedback. + Format: [{ attempt: int, ai_feedback: str, score: int, student_answer: str }] Returns: Dict with feedback data @@ -63,13 +67,18 @@ def generate_instant_feedback( if existing_feedback: # Check status of existing feedback if existing_feedback.generation_status == 'completed': - # Only return if feedback_data actually exists (not NULL) if existing_feedback.feedback_data: - logger.info(f"✅ Returning existing completed feedback for answer {student_answer.id}") - return self._feedback_model_to_dict(existing_feedback) + # Check if this is fallback that should be upgraded + is_fallback = existing_feedback.feedback_data.get('fallback', False) + if is_fallback: + logger.info(f"Fallback feedback detected for answer {student_answer.id} — regenerating with real AI") + # Continue with generation (don't return early) + else: + logger.info(f"Returning existing completed feedback for answer {student_answer.id}") + return self._feedback_model_to_dict(existing_feedback) else: # Status says 'completed' but data is NULL - regenerate - logger.warning(f"⚠️ Status is 'completed' but feedback_data is NULL - regenerating for answer {student_answer.id}") + logger.warning(f"Status is 'completed' but feedback_data is NULL - regenerating for answer {student_answer.id}") # Continue with generation elif existing_feedback.generation_status in ['pending', 'generating']: @@ -98,7 +107,7 @@ def generate_instant_feedback( existing_feedback = create_pending_feedback( db=db, answer_id=student_answer.id, - timeout_seconds=120 # 2 minutes timeout + timeout_seconds=45 # 45 second timeout ) # ========== STEP 3: Update status to 'generating' ========== @@ -112,26 +121,32 @@ def generate_instant_feedback( # ========== STEP 4: Load question and module data ========== question = get_question_by_id(db, question_id) if not question: - mark_feedback_failed( - db=db, - answer_id=student_answer.id, - error_message="Question not found", - error_type="data_error" - ) - return self._error_response("Question not found") + logger.error(f"❌ Question {question_id} not found — returning fallback feedback") + fallback = self._build_universal_fallback(student_answer, question_id, module_id, db) + try: + complete_feedback_generation(db=db, answer_id=student_answer.id, + feedback_data={"explanation": fallback["explanation"], "fallback": True}, + is_correct=None, score=None, points_earned=0, points_possible=0, + criterion_scores={}, confidence_level="low", ai_model="fallback") + except Exception: + pass + return fallback update_feedback_status(db, student_answer.id, 'generating', 20) # Get module configuration module = db.query(Module).filter(Module.id == module_id).first() if not module: - mark_feedback_failed( - db=db, - answer_id=student_answer.id, - error_message="Module not found", - error_type="data_error" - ) - return self._error_response("Module not found") + logger.error(f"❌ Module {module_id} not found — returning fallback feedback") + fallback = self._build_universal_fallback(student_answer, question_id, module_id, db) + try: + complete_feedback_generation(db=db, answer_id=student_answer.id, + feedback_data={"explanation": fallback["explanation"], "fallback": True}, + is_correct=None, score=None, points_earned=0, points_possible=0, + criterion_scores={}, confidence_level="low", ai_model="fallback") + except Exception: + pass + return fallback # Get rubric configuration (merges with defaults) rubric = get_module_rubric(db, module_id) @@ -163,7 +178,7 @@ def generate_instant_feedback( student_answer=student_answer_text, module_id=module_id, max_chunks=rag_settings.get("max_context_chunks", 3), - similarity_threshold=rag_settings.get("similarity_threshold", 0.7), + similarity_threshold=rag_settings.get("similarity_threshold", 0.3), include_document_locations=rag_settings.get("include_document_locations", True) ) logger.info(f"✅ RAG context retrieved: has_context={rag_context.get('has_context', False)}") @@ -182,13 +197,17 @@ def generate_instant_feedback( update_feedback_status(db, student_answer.id, 'generating', 50) # ========== STEP 5: Generate feedback based on question type ========== + # Use GPT-4o-mini for MCQ types — binary grading doesn't need full GPT-4 + mcq_model = "gpt-4o-mini" + if question.type == 'mcq': feedback = self._analyze_mcq_answer( student_answer=student_answer_text, question=question, - ai_model=ai_model, + ai_model=mcq_model, rubric=rubric, - rag_context=rag_context + rag_context=rag_context, + previous_feedback=previous_feedback_context ) elif question.type == 'fill_blank': feedback = self._analyze_fill_blank_answer( @@ -196,15 +215,17 @@ def generate_instant_feedback( question=question, ai_model=ai_model, rubric=rubric, - rag_context=rag_context + rag_context=rag_context, + previous_feedback=previous_feedback_context ) elif question.type == 'mcq_multiple': feedback = self._analyze_mcq_multiple_answer( student_answer=student_answer.answer, question=question, - ai_model=ai_model, + ai_model=mcq_model, rubric=rubric, - rag_context=rag_context + rag_context=rag_context, + previous_feedback=previous_feedback_context ) elif question.type == 'multi_part': feedback = self._analyze_multi_part_answer( @@ -212,7 +233,8 @@ def generate_instant_feedback( question=question, ai_model=ai_model, rubric=rubric, - rag_context=rag_context + rag_context=rag_context, + previous_feedback=previous_feedback_context ) else: # Default to text answer for short/long types @@ -221,10 +243,12 @@ def generate_instant_feedback( question=question, ai_model=ai_model, rubric=rubric, - rag_context=rag_context + rag_context=rag_context, + previous_feedback=previous_feedback_context ) # Prepare feedback data for storage + is_fallback = feedback.get("fallback", False) feedback_data = { "explanation": feedback.get("explanation", ""), "improvement_hint": feedback.get("improvement_hint"), @@ -234,13 +258,20 @@ def generate_instant_feedback( "selected_option": feedback.get("selected_option"), "correct_option": feedback.get("correct_option"), "available_options": feedback.get("available_options"), - "model_used": ai_model, + "model_used": "fallback" if is_fallback else ai_model, "confidence_level": feedback.get("confidence_level", "medium"), "feedback_type": feedback.get("feedback_type"), "used_rag": rag_context is not None and rag_context.get("has_context", False), - "rag_sources": rag_context.get("sources", []) if rag_context and rag_context.get("has_context") else None + "rag_sources": rag_context.get("sources", []) if rag_context and rag_context.get("has_context") else None, + "fallback": is_fallback, + "error_type": feedback.get("_error_type"), + "error_message": feedback.get("_error_message"), } + # If this is fallback, mark the model accordingly so upgrades are triggered + if is_fallback: + ai_model = "fallback" + update_feedback_status(db, student_answer.id, 'generating', 90) # ========== STEP 6: Save completed feedback to database ========== @@ -265,14 +296,24 @@ def generate_instant_feedback( except Exception as db_error: logger.error(f"❌ Failed to save feedback to database: {str(db_error)}") logger.exception("Full traceback:") - # Mark as failed - mark_feedback_failed( - db=db, - answer_id=student_answer.id, - error_message=f"Database error: {str(db_error)}", - error_type="database_error" - ) - raise + # Retry the save once after rollback + try: + db.rollback() + db_feedback = complete_feedback_generation( + db=db, + answer_id=student_answer.id, + feedback_data=feedback_data, + is_correct=feedback.get("is_correct"), + score=feedback.get("correctness_score"), + points_earned=feedback.get("points_earned"), + points_possible=feedback.get("points_possible"), + criterion_scores=feedback.get("criterion_scores"), + confidence_level=feedback.get("confidence_level"), + ai_model=ai_model + ) + logger.info(f"✅ Feedback saved on retry after rollback") + except Exception: + logger.error("❌ DB save failed even after retry — returning feedback without persisting") # Return complete feedback for API response return { @@ -288,18 +329,34 @@ def generate_instant_feedback( logger.error(f"❌ Error generating feedback: {str(e)}") logger.exception("Full traceback:") - # Mark feedback as failed + # NEVER fail — always give the student fallback feedback + fallback = self._build_universal_fallback(student_answer, question_id, module_id, db) try: - mark_feedback_failed( + fallback_data = { + "explanation": fallback.get("explanation", ""), + "improvement_hint": fallback.get("improvement_hint"), + "concept_explanation": fallback.get("concept_explanation"), + "confidence_level": "low", + "feedback_type": fallback.get("feedback_type", "fallback"), + "fallback": True + } + complete_feedback_generation( db=db, answer_id=student_answer.id, - error_message=str(e), - error_type="generation_error" + feedback_data=fallback_data, + is_correct=fallback.get("is_correct"), + score=fallback.get("correctness_score"), + points_earned=fallback.get("points_earned"), + points_possible=fallback.get("points_possible"), + criterion_scores={}, + confidence_level="low", + ai_model="fallback" ) - except: - logger.error("Failed to mark feedback as failed") + logger.info(f"🛟 Saved fallback feedback for answer {student_answer.id}") + except Exception: + logger.error("❌ Even fallback save failed — returning in-memory fallback") - return self._error_response(f"Failed to generate feedback: {str(e)}") + return fallback def _get_ai_model_from_module(self, module: Optional[Module]) -> str: """Extract AI model from module configuration or use default""" @@ -337,7 +394,8 @@ def _analyze_mcq_answer( question: Question, ai_model: str, rubric: Dict[str, Any], - rag_context: Optional[Dict[str, Any]] = None + rag_context: Optional[Dict[str, Any]] = None, + previous_feedback: Optional[List[Dict[str, Any]]] = None ) -> Dict[str, Any]: """Analyze multiple choice question answer with rubric and RAG support""" @@ -378,7 +436,8 @@ def _analyze_mcq_answer( correct_answer=correct_answer, is_correct=is_correct, rubric=rubric, - rag_context=rag_context + rag_context=rag_context, + previous_feedback=previous_feedback ) # 🎯 LOG: OpenAI API call details @@ -457,7 +516,7 @@ def _analyze_mcq_answer( logger.info("💾 PARSED FEEDBACK DATA:") logger.info(f" ✓ is_correct: {feedback.get('is_correct')}") logger.info(f" ✓ correctness_score: {correctness_score}%") - logger.info(f" ✓ points_earned: {points_earned:.2f if points_earned else 0} / {question.points}") + logger.info(f" ✓ points_earned: {(points_earned or 0):.2f} / {question.points}") logger.info(f" ✓ confidence: {confidence}") logger.info(f" ✓ explanation: {feedback.get('explanation', '')[:100]}...") logger.info(f" ✓ improvement_hint: {feedback.get('improvement_hint', '')[:100]}...") @@ -467,16 +526,34 @@ def _analyze_mcq_answer( except json.JSONDecodeError as je: logger.error(f"JSON decode error: {str(je)}, Response: {feedback_text}") - return self._fallback_mcq_feedback(student_answer, correct_answer, options, is_correct, question.points) + fb = self._fallback_mcq_feedback(student_answer, correct_answer, options, is_correct, question.points, question.text) + fb["_error_type"] = "JSONDecodeError" + fb["_error_message"] = str(je)[:200] + return fb + except openai.AuthenticationError as e: + logger.error(f"OpenAI authentication error: {str(e)}") + fb = self._fallback_mcq_feedback(student_answer, correct_answer, options, is_correct, question.points, question.text) + fb["_error_type"] = "AuthenticationError" + fb["_error_message"] = str(e)[:200] + return fb except openai.APITimeoutError as e: - logger.error(f"⏱️ OpenAI timeout after retries: {str(e)}") - return self._fallback_mcq_feedback(student_answer, correct_answer, options, is_correct, question.points) + logger.error(f"OpenAI timeout after retries: {str(e)}") + fb = self._fallback_mcq_feedback(student_answer, correct_answer, options, is_correct, question.points, question.text) + fb["_error_type"] = "APITimeoutError" + fb["_error_message"] = str(e)[:200] + return fb except openai.RateLimitError as e: - logger.error(f"🚫 OpenAI rate limit exceeded: {str(e)}") - return self._fallback_mcq_feedback(student_answer, correct_answer, options, is_correct, question.points) + logger.error(f"OpenAI rate limit exceeded: {str(e)}") + fb = self._fallback_mcq_feedback(student_answer, correct_answer, options, is_correct, question.points, question.text) + fb["_error_type"] = "RateLimitError" + fb["_error_message"] = str(e)[:200] + return fb except Exception as e: logger.error(f"OpenAI API error: {str(e)}") - return self._fallback_mcq_feedback(student_answer, correct_answer, options, is_correct, question.points) + fb = self._fallback_mcq_feedback(student_answer, correct_answer, options, is_correct, question.points, question.text) + fb["_error_type"] = type(e).__name__ + fb["_error_message"] = str(e)[:200] + return fb def _analyze_text_answer( self, @@ -484,7 +561,8 @@ def _analyze_text_answer( question: Question, ai_model: str, rubric: Dict[str, Any], - rag_context: Optional[Dict[str, Any]] = None + rag_context: Optional[Dict[str, Any]] = None, + previous_feedback: Optional[List[Dict[str, Any]]] = None ) -> Dict[str, Any]: """Analyze text-based (short/essay) question answer with rubric and RAG support""" @@ -503,7 +581,8 @@ def _analyze_text_answer( student_answer=student_answer, reference_answer=correct_answer, rubric=rubric, - rag_context=rag_context + rag_context=rag_context, + previous_feedback=previous_feedback ) # 🎯 LOG: OpenAI API call details @@ -593,16 +672,34 @@ def _analyze_text_answer( except json.JSONDecodeError as je: logger.error(f"JSON decode error: {str(je)}, Response: {feedback_text}") - return self._fallback_text_feedback(student_answer, correct_answer, question.type, question.points) + fb = self._fallback_text_feedback(student_answer, correct_answer, question.type, question.points, question.text) + fb["_error_type"] = "JSONDecodeError" + fb["_error_message"] = str(je)[:200] + return fb + except openai.AuthenticationError as e: + logger.error(f"OpenAI authentication error: {str(e)}") + fb = self._fallback_text_feedback(student_answer, correct_answer, question.type, question.points, question.text) + fb["_error_type"] = "AuthenticationError" + fb["_error_message"] = str(e)[:200] + return fb except openai.APITimeoutError as e: - logger.error(f"⏱️ OpenAI timeout after retries: {str(e)}") - return self._fallback_text_feedback(student_answer, correct_answer, question.type, question.points) + logger.error(f"OpenAI timeout after retries: {str(e)}") + fb = self._fallback_text_feedback(student_answer, correct_answer, question.type, question.points, question.text) + fb["_error_type"] = "APITimeoutError" + fb["_error_message"] = str(e)[:200] + return fb except openai.RateLimitError as e: - logger.error(f"🚫 OpenAI rate limit exceeded: {str(e)}") - return self._fallback_text_feedback(student_answer, correct_answer, question.type, question.points) + logger.error(f"OpenAI rate limit exceeded: {str(e)}") + fb = self._fallback_text_feedback(student_answer, correct_answer, question.type, question.points, question.text) + fb["_error_type"] = "RateLimitError" + fb["_error_message"] = str(e)[:200] + return fb except Exception as e: logger.error(f"OpenAI API error: {str(e)}") - return self._fallback_text_feedback(student_answer, correct_answer, question.type, question.points) + fb = self._fallback_text_feedback(student_answer, correct_answer, question.type, question.points, question.text) + fb["_error_type"] = type(e).__name__ + fb["_error_message"] = str(e)[:200] + return fb def _format_options(self, options: Dict[str, str]) -> str: """Format MCQ options for prompt""" @@ -617,28 +714,50 @@ def _fallback_mcq_feedback( correct_answer: str, options: Dict[str, str], is_correct: bool, - question_points: float = 1.0 + question_points: float = 1.0, + question_text: str = "" ) -> Dict[str, Any]: - """Fallback feedback when AI fails""" - # Get the correct option text for better feedback - correct_option_text = options.get(correct_answer, correct_answer) if options else correct_answer - correct_display = f"{correct_answer} ({correct_option_text})" if options else correct_answer - - # Calculate points + """Fallback feedback when AI fails — NEVER reveals the correct answer.""" correctness_score = 100 if is_correct else 0 points_earned = (correctness_score / 100.0) * question_points + # Build question-aware feedback instead of generic boilerplate + selected_text = options.get(student_answer, student_answer) if options else student_answer + + if is_correct: + explanation = f"Your answer '{selected_text}' is correct. You demonstrated a solid understanding of this topic." + hint = "Great work! Keep building on this knowledge." + concept = f"This question tests your understanding of a key concept. Your correct answer shows you grasp the material well." + else: + explanation = ( + f"Your selection '{selected_text}' is not the best answer for this question. " + f"Think carefully about what the question is really asking and revisit the relevant topic in your course materials." + ) + hint = ( + f"Re-read the question: \"{question_text[:120]}{'...' if len(question_text) > 120 else ''}\" " + f"Consider what each option means in context and which one most directly addresses the question." + ) if question_text else ( + "Re-read the question carefully and consider all options. " + "Think about the underlying concept being tested." + ) + concept = ( + f"This question is about: \"{question_text[:150]}{'...' if len(question_text) > 150 else ''}\" " + f"Review the section in your course materials that covers this topic." + ) if question_text else ( + "Review the related section in your course materials to understand the concept this question covers." + ) + return { "is_correct": is_correct, "correctness_score": correctness_score, "points_earned": round(points_earned, 2), "points_possible": question_points, - "criterion_scores": {}, # Empty for fallback - "confidence_level": "low", # Fallback has low confidence + "criterion_scores": {}, + "confidence_level": "low", "feedback_type": "mcq", - "explanation": f"Your answer is {'correct' if is_correct else 'incorrect'}. The correct answer is {correct_display}.", - "improvement_hint": "Review the question and consider the key concepts being tested." if not is_correct else "Well done!", - "concept_explanation": "Please review the related course material.", + "explanation": explanation, + "improvement_hint": hint, + "concept_explanation": concept, "selected_option": student_answer, "correct_option": correct_answer, "available_options": options, @@ -650,26 +769,46 @@ def _fallback_text_feedback( student_answer: str, correct_answer: str, question_type: str, - question_points: float = 1.0 + question_points: float = 1.0, + question_text: str = "" ) -> Dict[str, Any]: """Fallback feedback for text answers when AI fails""" # Neutral score when AI unavailable correctness_score = 50 points_earned = (correctness_score / 100.0) * question_points + if question_text: + explanation = ( + f"Your answer has been submitted for the question: " + f"\"{question_text[:120]}{'...' if len(question_text) > 120 else ''}\" " + f"Detailed AI analysis is temporarily unavailable, but your teacher will review your response." + ) + hint = ( + f"Review the course materials related to this topic and compare your answer " + f"with what the materials explain about the concepts covered in this question." + ) + concept = ( + f"This question covers: \"{question_text[:150]}{'...' if len(question_text) > 150 else ''}\" " + f"Look for this topic in your course materials for a deeper understanding." + ) + else: + explanation = "Your answer has been submitted. Detailed AI analysis is temporarily unavailable." + hint = "Review the course materials related to this topic." + concept = "Please refer to course materials for concept review." + return { "is_correct": len(student_answer.strip()) > 0, "correctness_score": correctness_score, "points_earned": round(points_earned, 2), "points_possible": question_points, - "criterion_scores": {}, # Empty for fallback - "confidence_level": "low", # Fallback has low confidence + "criterion_scores": {}, + "confidence_level": "low", "feedback_type": question_type, - "explanation": "Your answer has been submitted. Please compare with the reference answer.", + "explanation": explanation, "strengths": ["Answer provided"], "weaknesses": ["Detailed analysis unavailable"], - "improvement_hint": "Review the reference answer and course materials.", - "concept_explanation": "Please refer to course materials for concept review.", + "improvement_hint": hint, + "concept_explanation": concept, "missing_concepts": [], "reference_answer": correct_answer, "fallback": True @@ -688,13 +827,97 @@ def _error_response(self, message: str) -> Dict[str, Any]: "confidence_level": "low" } + def _build_universal_fallback(self, student_answer, question_id, module_id, db) -> Dict[str, Any]: + """ + Build fallback feedback that ALWAYS succeeds, regardless of what went wrong. + This ensures the student never sees a failed state. + Includes question text for context-aware fallback messages. + """ + try: + question = get_question_by_id(db, question_id) + except Exception: + question = None + + q_type = question.type if question else "unknown" + q_points = question.points if question else 1.0 + q_text = question.text if question else "" + + if q_type == 'mcq': + correct_answer = (question.correct_option_id or question.correct_answer) if question else None + options = question.options or {} if question else {} + answer_text = self._extract_answer_text(student_answer.answer) if student_answer else "" + is_correct = None + if correct_answer and answer_text: + is_correct = answer_text.upper() == correct_answer.upper() + score = 100 if is_correct else (0 if is_correct is not None else None) + points_earned = (score / 100.0) * q_points if score is not None else 0 + + selected_text = options.get(answer_text, answer_text) if options else answer_text + + if is_correct: + explanation = f"Your answer '{selected_text}' is correct. Good understanding of this topic." + elif is_correct is not None: + explanation = ( + f"Your selection '{selected_text}' is not the best answer. " + f"Think about what the question is really asking and review the relevant topic." + ) + else: + explanation = "Your answer has been recorded." + + return { + "is_correct": is_correct, + "correctness_score": score, + "points_earned": round(points_earned, 2), + "points_possible": q_points, + "criterion_scores": {}, + "confidence_level": "low", + "feedback_type": "mcq", + "explanation": explanation, + "improvement_hint": ( + f"Re-read: \"{q_text[:120]}{'...' if len(q_text) > 120 else ''}\" " + f"and consider what each option means in context." + ) if q_text else "Review the question and related course materials.", + "concept_explanation": ( + f"This question covers: \"{q_text[:150]}{'...' if len(q_text) > 150 else ''}\" " + f"Find this topic in your course materials." + ) if q_text else "Review your course materials for this topic.", + "fallback": True + } + else: + # Generic fallback for text, fill_blank, mcq_multiple, multi_part, or unknown + return { + "is_correct": None, + "correctness_score": 50, + "points_earned": round(0.5 * q_points, 2), + "points_possible": q_points, + "criterion_scores": {}, + "confidence_level": "low", + "feedback_type": q_type, + "explanation": ( + f"Your answer has been submitted. Detailed AI feedback is temporarily unavailable, " + f"but your teacher will review your response." + ), + "improvement_hint": ( + f"Review the course materials related to: " + f"\"{q_text[:120]}{'...' if len(q_text) > 120 else ''}\"" + ) if q_text else "Review the course materials related to this question.", + "concept_explanation": ( + f"This question covers: \"{q_text[:150]}{'...' if len(q_text) > 150 else ''}\" " + f"Look for this topic in your course materials." + ) if q_text else "Refer to your course materials for this topic.", + "strengths": ["Answer submitted"], + "weaknesses": [], + "fallback": True + } + def _analyze_fill_blank_answer( self, student_answer: Dict[str, Any], question: Question, ai_model: str, rubric: Dict[str, Any], - rag_context: Optional[Dict[str, Any]] = None + rag_context: Optional[Dict[str, Any]] = None, + previous_feedback: Optional[List[Dict[str, Any]]] = None ) -> Dict[str, Any]: """Analyze fill-in-the-blank question answer with AI semantic matching""" from app.services.question_grading import QuestionGradingService @@ -705,7 +928,14 @@ def _analyze_fill_blank_answer( if not blank_configs: logger.error(f"Fill-blank question {question.id} has no blank configurations") - return self._error_response("Question configuration error") + return { + "is_correct": None, "correctness_score": 50, + "points_earned": round(0.5 * question.points, 2), "points_possible": question.points, + "criterion_scores": {}, "confidence_level": "low", "feedback_type": "fill_blank", + "explanation": "Your answer has been recorded. Detailed feedback is temporarily unavailable, but your teacher will review your response.", + "improvement_hint": "Review the course materials related to this question.", + "concept_explanation": "Please refer to your course materials.", "fallback": True + } # Extract student answers from answer data # Format: {"blanks": {0: "answer1", 1: "answer2", ...}} @@ -737,6 +967,16 @@ def _analyze_fill_blank_answer( num_correct = sum(1 for r in grading_result['blank_results'] if r['is_correct']) num_incorrect = len(grading_result['blank_results']) - num_correct + # Build previous feedback section if available + previous_feedback_section = "" + if previous_feedback: + previous_feedback_section = "\n" + _build_previous_feedback_section(previous_feedback) + "\n" + + # Build RAG context section if available + rag_section = "" + if rag_context and rag_context.get("has_context"): + rag_section = "\n" + rag_context["formatted_context"] + "\n" + prompt = f"""You are an educational AI providing feedback on a fill-in-the-blank answer. Question: {question.text} @@ -746,7 +986,7 @@ def _analyze_fill_blank_answer( Score: {grading_result['earned_points']}/{grading_result['total_points']} ({grading_result['percentage']:.1f}%) Correct blanks: {num_correct}, Incorrect blanks: {num_incorrect} - +{rag_section}{previous_feedback_section} ⚠️ CRITICAL INSTRUCTION: NEVER reveal the accepted/correct answers for any blank in your feedback. DO NOT tell the student what the correct answer should be. Instead: 1. **Analyze the question content**: Understand what topic/concept this question is testing @@ -763,7 +1003,7 @@ def _analyze_fill_blank_answer( {{ "explanation": "Detailed analysis of their performance. Start by explaining how many blanks they filled correctly (e.g., 'You correctly filled X out of Y blanks'). Then provide insight into what this question is testing - what topic or concept is it about? Why is this knowledge important? Be specific to THIS question's content and subject matter.", - "improvement_hint": "Specific, contextual guidance related to THIS question's topic. Don't just say 'review the context' - instead, help them understand what type of knowledge or concepts they need for these specific blanks. For example: 'Consider the technical terminology related to [topic]' or 'Think about the grammatical structure needed here - this sentence is describing [concept]'. Reference the actual subject matter.", + "improvement_hint": "Specific, contextual guidance related to THIS question's topic. {("If course material sources are provided above, ALWAYS include an EXACT document reference like 'Review [Document Name], Page X' or 'See the section on [topic] in [Document]'. " if rag_context and rag_context.get('has_context') else "")}Don't just say 'review the context' - instead, help them understand what type of knowledge or concepts they need for these specific blanks.", "concept_explanation": "Explain the key concept, topic, or knowledge domain that THIS specific question is testing. What should students understand about [this topic] to fill in these blanks correctly? Be specific to the question content - what subject area is this? What principles or terminology are relevant?" }} @@ -777,9 +1017,9 @@ def _analyze_fill_blank_answer( Make your feedback detailed, contextual, and helpful!""" try: - response = self.client.chat.completions.create( - model=ai_model, + response = self.client.create_chat_completion( messages=[{"role": "user", "content": prompt}], + model=ai_model, temperature=0.4, max_tokens=600 ) @@ -816,6 +1056,8 @@ def _analyze_fill_blank_answer( except Exception as e: logger.error(f"AI feedback generation failed for fill-blank: {str(e)}") + _fill_blank_error_type = type(e).__name__ + _fill_blank_error_message = str(e)[:200] # Return grading results without AI-generated text and WITHOUT revealing correct answers # Build detailed explanation from blank results WITHOUT revealing which blanks are wrong @@ -853,7 +1095,9 @@ def _analyze_fill_blank_answer( "concept_explanation": "Fill-in-the-blank questions test your understanding of specific terms and concepts. Read the entire sentence or paragraph carefully, paying attention to clues in the surrounding text. Make sure your answers are grammatically correct and contextually appropriate.", "grading_details": grading_result, "feedback_type": "fill_blank", - "fallback": True + "fallback": True, + "_error_type": _fill_blank_error_type, + "_error_message": _fill_blank_error_message, } def _analyze_mcq_multiple_answer( @@ -862,7 +1106,8 @@ def _analyze_mcq_multiple_answer( question: Question, ai_model: str, rubric: Dict[str, Any], - rag_context: Optional[Dict[str, Any]] = None + rag_context: Optional[Dict[str, Any]] = None, + previous_feedback: Optional[List[Dict[str, Any]]] = None ) -> Dict[str, Any]: """Analyze multiple-correct MCQ answer""" from app.services.question_grading import QuestionGradingService @@ -880,7 +1125,14 @@ def _analyze_mcq_multiple_answer( if not correct_option_ids: logger.error(f"MCQ-multiple question {question.id} has no correct options configured") - return self._error_response("Question configuration error") + return { + "is_correct": None, "correctness_score": 50, + "points_earned": round(0.5 * question.points, 2), "points_possible": question.points, + "criterion_scores": {}, "confidence_level": "low", "feedback_type": "mcq_multiple", + "explanation": "Your answer has been recorded. Detailed feedback is temporarily unavailable, but your teacher will review your response.", + "improvement_hint": "Review the course materials related to this question.", + "concept_explanation": "Please refer to your course materials.", "fallback": True + } # Extract selected options from answer data # Format: {"selected_options": ["A", "B", "C"]} @@ -901,6 +1153,16 @@ def _analyze_mcq_multiple_answer( selected_text = ", ".join([f"{opt}: {options.get(opt, '')}" for opt in selected_options]) correct_text = ", ".join([f"{opt}: {options.get(opt, '')}" for opt in correct_option_ids]) + # Build previous feedback section if available + previous_feedback_section = "" + if previous_feedback: + previous_feedback_section = "\n" + _build_previous_feedback_section(previous_feedback) + "\n" + + # Build RAG context section if available + rag_section = "" + if rag_context and rag_context.get("has_context"): + rag_section = "\n" + rag_context["formatted_context"] + "\n" + prompt = f"""You are an educational AI providing feedback on a multiple-choice question with multiple correct answers. Question: {question.text} @@ -917,7 +1179,7 @@ def _analyze_mcq_multiple_answer( - Incorrectly selected: {', '.join(grading_result['incorrectly_selected']) or 'None'} - Missed correct options: {', '.join(grading_result['missed_correct']) or 'None'} - Score: {grading_result['score']:.1f}% - +{rag_section}{previous_feedback_section} ⚠️ CRITICAL INSTRUCTION: NEVER reveal which specific options are correct in your feedback. DO NOT mention option letters/IDs (A, B, C, etc.) as being correct or incorrect. Instead: 1. **Analyze the question content**: Understand what topic/concept this question is testing @@ -934,13 +1196,13 @@ def _analyze_mcq_multiple_answer( {{ "explanation": "Detailed analysis of their performance. Start by acknowledging what they selected and explain the performance (e.g., 'You selected X option(s), and got Y correct'). Then provide insight into what this question is testing and why understanding [specific concept] is important. Be specific to THIS question's content.", - "improvement_hint": "Specific, contextual guidance related to THIS question's topic. Don't just say 'review all options' - instead, help them think about the specific concepts, principles, or criteria they should consider when evaluating the options. Reference the actual subject matter of the question. Guide them to think differently about the topic being tested.", + "improvement_hint": "Specific, contextual guidance related to THIS question's topic. {("If course material sources are provided above, ALWAYS include an EXACT document reference like 'Review [Document Name], Page X' or 'See the section on [topic] in [Document]'. " if rag_context and rag_context.get('has_context') else "")}Don't just say 'review all options' - instead, help them think about the specific concepts they should consider.", "concept_explanation": "Explain the key concept or principle that THIS specific question is testing. What should students understand about [this topic] to answer correctly? What's the learning objective? Be specific to the question content, not generic." }} Example of GOOD feedback (detailed and contextual): -- "This question tests your understanding of data structures in computer science. When selecting the best data structure for a task, consider factors like time complexity, space efficiency, and the specific operations you need to perform..." +- "This question tests your understanding of data structures in computer science. To better understand this, review Lab 3, Page 5, the section on arrays vs linked lists..." Example of BAD feedback (too generic - AVOID THIS): - "Review all the options carefully. Think about what makes an option correct." @@ -948,9 +1210,9 @@ def _analyze_mcq_multiple_answer( Make your feedback detailed, contextual, and helpful!""" try: - response = self.client.chat.completions.create( - model=ai_model, + response = self.client.create_chat_completion( messages=[{"role": "user", "content": prompt}], + model=ai_model, temperature=0.4, max_tokens=700 ) @@ -990,6 +1252,8 @@ def _analyze_mcq_multiple_answer( except Exception as e: logger.error(f"AI feedback generation failed for mcq-multiple: {str(e)}") + _mcq_mult_error_type = type(e).__name__ + _mcq_mult_error_message = str(e)[:200] # Build comprehensive fallback feedback WITHOUT revealing correct answers correctly_selected = grading_result.get('correctly_selected', []) @@ -1051,7 +1315,9 @@ def _analyze_mcq_multiple_answer( "correct_options": correct_option_ids, "available_options": options, "feedback_type": "mcq_multiple", - "fallback": True + "fallback": True, + "_error_type": _mcq_mult_error_type, + "_error_message": _mcq_mult_error_message, } def _analyze_multi_part_answer( @@ -1060,7 +1326,8 @@ def _analyze_multi_part_answer( question: Question, ai_model: str, rubric: Dict[str, Any], - rag_context: Optional[Dict[str, Any]] = None + rag_context: Optional[Dict[str, Any]] = None, + previous_feedback: Optional[List[Dict[str, Any]]] = None ) -> Dict[str, Any]: """Analyze multi-part question answer""" # Get extended config with sub-questions @@ -1069,7 +1336,14 @@ def _analyze_multi_part_answer( if not sub_questions: logger.error(f"Multi-part question {question.id} has no sub-questions configured") - return self._error_response("Question configuration error") + return { + "is_correct": None, "correctness_score": 50, + "points_earned": round(0.5 * question.points, 2), "points_possible": question.points, + "criterion_scores": {}, "confidence_level": "low", "feedback_type": "multi_part", + "explanation": "Your answer has been recorded. Detailed feedback is temporarily unavailable, but your teacher will review your response.", + "improvement_hint": "Review the course materials related to this question.", + "concept_explanation": "Please refer to your course materials.", "fallback": True + } # Extract sub-answers from answer data # Format: {"sub_answers": {"1a": "answer", "1b": {"selected_option": "A"}, ...}} @@ -1116,9 +1390,9 @@ def _analyze_multi_part_answer( "reasoning": "brief explanation" }}""" - response = self.client.chat.completions.create( - model=ai_model, + response = self.client.create_chat_completion( messages=[{"role": "user", "content": grade_prompt}], + model=ai_model, temperature=0.3, max_tokens=300 ) @@ -1180,6 +1454,16 @@ def _analyze_multi_part_answer( correct_count = sum(1 for r in sub_results if r['is_correct']) total_count = len(sub_results) + # Build previous feedback section if available + previous_feedback_section = "" + if previous_feedback: + previous_feedback_section = "\n" + _build_previous_feedback_section(previous_feedback) + "\n" + + # Build RAG context section if available + rag_section = "" + if rag_context and rag_context.get("has_context"): + rag_section = "\n" + rag_context["formatted_context"] + "\n" + prompt = f"""You are an educational AI providing feedback on a multi-part question. Main Question: {question.text} @@ -1189,7 +1473,7 @@ def _analyze_multi_part_answer( Total Score: {earned_points}/{total_points} ({score:.1f}%) Correct sub-questions: {correct_count}/{total_count} - +{rag_section}{previous_feedback_section} ⚠️ CRITICAL INSTRUCTION: NEVER reveal which specific sub-questions (by ID like "1a", "1b", etc.) are correct or incorrect in your feedback. DO NOT tell the student which parts they got wrong. Instead: 1. **Analyze the question content**: Understand what overall topic/concept this multi-part question is testing @@ -1206,13 +1490,13 @@ def _analyze_multi_part_answer( {{ "explanation": "Detailed analysis of their overall performance. Start by explaining how many parts they answered correctly (e.g., 'You answered X out of Y parts correctly'). Then provide insight into what this multi-part question is testing - what is the overall topic? How do the different parts relate to this topic? Why is understanding this important? Be specific to THIS question's content and subject matter.", - "improvement_hint": "Specific, contextual guidance related to THIS question's overall topic. Don't just say 'review all parts' - instead, help them understand the key concepts they need to grasp to answer all parts successfully. For example: 'Consider how [concept A] relates to [concept B] in the context of [topic]' or 'Think about the progression from [idea] to [result] when considering all the parts together'. Guide them to think holistically about the topic.", + "improvement_hint": "Specific, contextual guidance related to THIS question's overall topic. {("If course material sources are provided above, ALWAYS include an EXACT document reference like 'Review [Document Name], Page X' or 'See the section on [topic] in [Document]'. " if rag_context and rag_context.get('has_context') else "")}Don't just say 'review all parts' - instead, help them understand the key concepts they need to grasp to answer all parts successfully.", - "concept_explanation": "Explain the overarching concept or principle that connects all the sub-questions together. What is the main learning objective? How do the different parts build on each other? Be specific to this question's topic - what subject area is this? What should students understand to answer all parts correctly?" + "concept_explanation": "Explain the overarching concept or principle that connects all the sub-questions together. What is the main learning objective? How do the different parts build on each other? Be specific to this question's topic." }} Example of GOOD feedback (detailed and contextual): -- "This multi-part question tests your understanding of photosynthesis and how its different stages work together. The parts explore the light-dependent and light-independent reactions and how they connect. Consider the flow of energy and matter through the entire process..." +- "This multi-part question tests your understanding of photosynthesis. To review this topic, see Lab 4, Pages 2-3, the section on light-dependent reactions..." Example of BAD feedback (too generic - AVOID THIS): - "Review each part carefully and make sure you understand what each one is asking." @@ -1220,9 +1504,9 @@ def _analyze_multi_part_answer( Make your feedback detailed, contextual, and helpful!""" try: - response = self.client.chat.completions.create( - model=ai_model, + response = self.client.create_chat_completion( messages=[{"role": "user", "content": prompt}], + model=ai_model, temperature=0.4, max_tokens=700 ) @@ -1260,6 +1544,8 @@ def _analyze_multi_part_answer( except Exception as e: logger.error(f"AI feedback generation failed for multi-part: {str(e)}") + _multi_part_error_type = type(e).__name__ + _multi_part_error_message = str(e)[:200] # Build detailed explanation from sub-results WITHOUT revealing which parts are wrong correct_count = sum(1 for r in sub_results if r['is_correct']) @@ -1295,7 +1581,9 @@ def _analyze_multi_part_answer( "sub_total_points": total_points, # Sub-question points (for internal tracking) "sub_earned_points": earned_points, # Sub-question earned points (for internal tracking) "feedback_type": "multi_part", - "fallback": True + "fallback": True, + "_error_type": _multi_part_error_type, + "_error_message": _multi_part_error_message, } def _feedback_model_to_dict(self, feedback_model) -> Dict[str, Any]: diff --git a/Backend/app/services/feedback_worker.py b/Backend/app/services/feedback_worker.py new file mode 100644 index 0000000..8afbb4a --- /dev/null +++ b/Backend/app/services/feedback_worker.py @@ -0,0 +1,528 @@ +""" +Feedback Worker — concurrent background workers that drain the FeedbackJob queue. + +Uses ThreadPoolExecutor(max_workers=10) for concurrent OpenAI calls. +Detects fallback results and retries instead of silently marking them as done. +Jobs survive server restarts because they live in the database. +""" + +import json +import logging +import threading +import time +import traceback +from concurrent.futures import ThreadPoolExecutor, Future +from datetime import datetime, timezone, timedelta +from typing import List +from uuid import UUID + +from app.database import SessionLocal +from app.models.feedback_job import FeedbackJob +from app.models.student_answer import StudentAnswer +from app.models.ai_feedback import AIFeedback +from app.models.question import Question +from app.models.test_submission import TestSubmission +from app.services.ai_feedback import AIFeedbackService +from app.crud.ai_feedback import mark_feedback_failed + +logger = logging.getLogger(__name__) + +# How long a job can sit in 'processing' before we assume the worker died +STALE_LOCK_SECONDS = 120 + +# Sleep between poll cycles when the queue is empty +POLL_INTERVAL_EMPTY = 1.0 + +# Sleep between processing consecutive batches +POLL_INTERVAL_BUSY = 0.1 + +# Concurrent worker threads — 10 parallel OpenAI calls +# At ~2s per call, this processes ~300 questions/minute +MAX_WORKERS = 10 + +# Global flag to stop the worker thread gracefully +_stop_event = threading.Event() +_worker_thread: threading.Thread | None = None + + +# ─── public API ─────────────────────────────────────────────── + + +def create_feedback_job( + db, + answer_id, + student_id: str, + module_id: str, + attempt: int, + priority: int = 1, + previous_feedback_context=None, +): + """ + Insert a FeedbackJob row. Called from the submit-test endpoint. + """ + previous_json = None + if previous_feedback_context: + try: + previous_json = json.dumps(previous_feedback_context) + except Exception: + previous_json = None + + job = FeedbackJob( + answer_id=answer_id if isinstance(answer_id, UUID) else UUID(str(answer_id)), + student_id=student_id, + module_id=module_id if isinstance(module_id, UUID) else UUID(str(module_id)), + attempt=attempt, + priority=priority, + previous_feedback_json=previous_json, + ) + db.add(job) + db.commit() + db.refresh(job) + logger.info(f"[worker] Created job {job.id} for answer {answer_id} (priority={priority})") + return job + + +def recover_stale_jobs(): + """ + Called once at startup. + 1. Any job stuck in 'processing' gets reset to 'queued' (server crashed mid-work). + 2. Any job in 'retry' gets reset to 'queued'. + """ + db = SessionLocal() + try: + stuck = ( + db.query(FeedbackJob) + .filter(FeedbackJob.status.in_(["processing", "retry"])) + .all() + ) + for job in stuck: + job.status = "queued" + job.locked_at = None + job.error_message = ( + f"Reset on startup (was {job.status})" + if not job.error_message + else job.error_message + ) + logger.info(f"[worker] Recovered stale job {job.id} (answer {job.answer_id})") + if stuck: + db.commit() + logger.info(f"[worker] Recovered {len(stuck)} stale jobs on startup") + except Exception as e: + logger.error(f"[worker] Error recovering stale jobs: {e}") + db.rollback() + finally: + db.close() + + +def start_worker(): + """Start the background worker thread (idempotent).""" + global _worker_thread + if _worker_thread is not None and _worker_thread.is_alive(): + logger.info("[worker] Worker already running") + return + _stop_event.clear() + _worker_thread = threading.Thread(target=_worker_loop, daemon=True, name="feedback-worker") + _worker_thread.start() + logger.info(f"[worker] Feedback worker started (max_workers={MAX_WORKERS})") + + +def stop_worker(): + """Signal the worker to stop (used in tests / shutdown).""" + _stop_event.set() + if _worker_thread is not None: + _worker_thread.join(timeout=15) + logger.info("[worker] Feedback worker stopped") + + +def get_queue_stats(): + """Return job queue statistics for the diagnostics endpoint.""" + db = SessionLocal() + try: + from sqlalchemy import func + stats = dict( + db.query(FeedbackJob.status, func.count()) + .group_by(FeedbackJob.status) + .all() + ) + return stats + except Exception as e: + logger.error(f"[worker] Error getting queue stats: {e}") + return {} + finally: + db.close() + + +# ─── internal loop (concurrent) ─────────────────────────────── + + +def _worker_loop(): + """Main loop: claim batches of jobs, process concurrently with ThreadPoolExecutor.""" + logger.info(f"[worker] Worker loop starting (max_workers={MAX_WORKERS})") + + executor = ThreadPoolExecutor(max_workers=MAX_WORKERS, thread_name_prefix="fb-worker") + active_futures: dict[Future, UUID] = {} # future -> job_id + _stale_check_counter = 0 + + try: + while not _stop_event.is_set(): + try: + # Only check stale jobs every 30 iterations (~30s) to reduce DB load + _stale_check_counter += 1 + if _stale_check_counter >= 30: + _stale_check_counter = 0 + _unlock_stale_jobs() + + # Clean up completed futures + done_futures = [f for f in active_futures if f.done()] + for f in done_futures: + job_id = active_futures.pop(f) + try: + f.result() # raise any exception from the thread + except Exception as e: + logger.error(f"[worker] Future for job {job_id} raised: {e}") + + # How many slots are free? + available_slots = MAX_WORKERS - len(active_futures) + + if available_slots > 0: + jobs = _claim_next_jobs(limit=available_slots) + + if jobs: + for job_id in jobs: + future = executor.submit(_process_single_job, job_id) + active_futures[future] = job_id + time.sleep(POLL_INTERVAL_BUSY) + else: + # No jobs available — sleep longer + _stop_event.wait(timeout=POLL_INTERVAL_EMPTY) + else: + # All slots busy — wait briefly for one to finish + time.sleep(POLL_INTERVAL_BUSY) + + except Exception as e: + logger.error(f"[worker] Unexpected error in worker loop: {e}") + traceback.print_exc() + time.sleep(5) + finally: + logger.info("[worker] Shutting down executor...") + executor.shutdown(wait=True, cancel_futures=False) + logger.info("[worker] Worker loop exited") + + +def _claim_next_jobs(limit: int) -> List[UUID]: + """ + Atomically claim up to `limit` queued jobs by setting them to 'processing'. + Uses FOR UPDATE SKIP LOCKED for safe concurrency. + Returns list of job IDs that were claimed. + """ + db = SessionLocal() + claimed_ids = [] + try: + jobs = ( + db.query(FeedbackJob) + .filter(FeedbackJob.status == "queued") + .order_by(FeedbackJob.priority, FeedbackJob.created_at) + .with_for_update(skip_locked=True) + .limit(limit) + .all() + ) + if not jobs: + return [] + + now = datetime.now(timezone.utc) + for job in jobs: + job.status = "processing" + job.locked_at = now + claimed_ids.append(job.id) + + db.commit() + + if claimed_ids: + logger.info(f"[worker] Claimed {len(claimed_ids)} jobs: {[str(jid)[:8] for jid in claimed_ids]}") + + return claimed_ids + + except Exception as e: + logger.error(f"[worker] Error claiming jobs: {e}") + db.rollback() + return [] + finally: + db.close() + + +def _process_single_job(job_id: UUID): + """ + Process a single job in its own DB session (thread-safe). + Each worker thread gets its own session. + """ + db = SessionLocal() + try: + job = db.query(FeedbackJob).filter(FeedbackJob.id == job_id).first() + if not job: + logger.error(f"[worker] Job {job_id} not found after claiming") + return + + logger.info( + f"[worker] Processing job {job.id} | answer={job.answer_id} | " + f"retry={job.retry_count}/{job.max_retries}" + ) + + _generate_feedback_for_job(db, job) + + except Exception as e: + logger.error(f"[worker] Error in _process_single_job({job_id}): {e}") + traceback.print_exc() + db.rollback() + finally: + db.close() + + +def _unlock_stale_jobs(): + """Reset jobs stuck in 'processing' for too long (worker died).""" + db = SessionLocal() + try: + cutoff = datetime.now(timezone.utc) - timedelta(seconds=STALE_LOCK_SECONDS) + stale = ( + db.query(FeedbackJob) + .filter( + FeedbackJob.status == "processing", + FeedbackJob.locked_at.isnot(None), + FeedbackJob.locked_at < cutoff, + ) + .all() + ) + for job in stale: + job.status = "queued" + job.locked_at = None + job.error_message = f"Stale lock reset after {STALE_LOCK_SECONDS}s" + logger.warning(f"[worker] Unlocked stale job {job.id}") + if stale: + db.commit() + except Exception as e: + logger.error(f"[worker] Error unlocking stale jobs: {e}") + db.rollback() + finally: + db.close() + + +def _generate_feedback_for_job(db, job: FeedbackJob): + """Run AIFeedbackService for a single job, then update status. + KEY FIX: Detect fallback results and retry instead of silently accepting them.""" + try: + answer = db.query(StudentAnswer).filter(StudentAnswer.id == job.answer_id).first() + if not answer: + job.status = "failed" + job.error_message = "Answer not found" + job.completed_at = datetime.now(timezone.utc) + db.commit() + logger.error(f"[worker] Answer {job.answer_id} not found — marking job failed") + mark_feedback_failed(db, job.answer_id, "Answer not found", "data_error") + return + + # Reset existing ai_feedback row so generate_instant_feedback + # doesn't short-circuit on stale completed/fallback data + existing_fb = db.query(AIFeedback).filter(AIFeedback.answer_id == job.answer_id).first() + if existing_fb: + is_fallback = (existing_fb.feedback_data or {}).get('fallback', False) + needs_reset = ( + existing_fb.generation_status in ('failed', 'timeout') + or is_fallback + or existing_fb.feedback_data is None + ) + if needs_reset: + existing_fb.generation_status = 'pending' + existing_fb.generation_progress = 0 + existing_fb.feedback_data = None + existing_fb.error_message = None + existing_fb.error_type = None + existing_fb.completed_at = None + existing_fb.started_at = datetime.now(timezone.utc) + db.commit() + logger.info(f"[worker] Reset ai_feedback row for answer {job.answer_id} (was {'fallback' if is_fallback else existing_fb.generation_status})") + + # Deserialize previous feedback context if present + previous_feedback_context = None + if job.previous_feedback_json: + try: + all_attempts_context = json.loads(job.previous_feedback_json) + # Extract per-question feedback from the context list + question_id_str = str(answer.question_id) + question_previous_feedback = [] + for attempt_ctx in all_attempts_context: + for fb in attempt_ctx.get("feedback", []): + if fb.get("question_id") == question_id_str: + question_previous_feedback.append({ + "attempt": attempt_ctx.get("attempt"), + "ai_feedback": fb.get("ai_feedback"), + "score": fb.get("score"), + "student_answer": fb.get("student_answer"), + }) + if question_previous_feedback: + previous_feedback_context = question_previous_feedback + except Exception as ctx_err: + logger.warning(f"[worker] Could not parse previous_feedback_json: {ctx_err}") + + feedback_service = AIFeedbackService() + result = feedback_service.generate_instant_feedback( + db=db, + student_answer=answer, + question_id=str(answer.question_id), + module_id=str(job.module_id), + previous_feedback_context=previous_feedback_context, + ) + + # ── KEY FIX: Check if the result is a fallback ── + is_fallback = result.get("fallback", False) if isinstance(result, dict) else False + error_type = result.get("_error_type") or (result.get("error_type") if isinstance(result, dict) else None) + + if is_fallback and error_type: + # Fallback due to an actual error (OpenAI failure, JSON parse, etc.) + job.retry_count += 1 + error_msg = result.get("_error_message") or result.get("error_message") or "Unknown error" + job.error_message = f"{error_type}: {error_msg}" + + if job.retry_count >= job.max_retries: + # Exhausted retries — accept the fallback + job.status = "done" + job.completed_at = datetime.now(timezone.utc) + job.locked_at = None + db.commit() + logger.warning( + f"[worker] Job {job.id} accepting fallback after {job.retry_count} retries " + f"({error_type}: {error_msg[:100]})" + ) + else: + # Re-queue for retry + job.status = "queued" + job.locked_at = None + db.commit() + logger.info( + f"[worker] Job {job.id} got fallback ({error_type}) — " + f"re-queuing (retry {job.retry_count}/{job.max_retries})" + ) + # Brief backoff before retry becomes available + time.sleep(min(2 ** job.retry_count, 10)) + return # Don't calculate score yet — job will be retried + else: + # Real AI feedback OR fallback without error (e.g., missing question data) + job.status = "done" + job.completed_at = datetime.now(timezone.utc) + job.locked_at = None + db.commit() + if is_fallback: + logger.info(f"[worker] Job {job.id} completed with fallback (no retryable error)") + else: + logger.info(f"[worker] Job {job.id} completed with real AI feedback") + + # Check if all jobs for this attempt are done and calculate score + calculate_test_score(job.student_id, str(job.module_id), job.attempt) + + except Exception as e: + logger.error(f"[worker] Job {job.id} failed: {e}") + traceback.print_exc() + + db.rollback() + # Re-fetch job after rollback + job = db.query(FeedbackJob).filter(FeedbackJob.id == job.id).first() + if not job: + return + + job.retry_count += 1 + job.error_message = str(e)[:500] + job.locked_at = None + + if job.retry_count >= job.max_retries: + job.status = "failed" + job.completed_at = datetime.now(timezone.utc) + logger.error(f"[worker] Job {job.id} exhausted retries ({job.max_retries})") + # Mark the ai_feedback row as failed too + try: + mark_feedback_failed(db, job.answer_id, f"Exhausted {job.max_retries} retries: {str(e)[:200]}", "generation_error") + except Exception: + pass + else: + job.status = "queued" # re-queue for retry + logger.info(f"[worker] Job {job.id} re-queued (retry {job.retry_count}/{job.max_retries})") + + db.commit() + + +def calculate_test_score(student_id: str, module_id: str, attempt: int): + """ + After all jobs for an attempt are done, calculate and save the total test score. + Called from the worker after confirming all jobs are complete. + """ + db = SessionLocal() + try: + # Check if ALL jobs for this student/module/attempt are done + pending = ( + db.query(FeedbackJob) + .filter( + FeedbackJob.student_id == student_id, + FeedbackJob.module_id == UUID(module_id) if isinstance(module_id, str) else module_id, + FeedbackJob.attempt == attempt, + FeedbackJob.status.in_(["queued", "processing", "retry"]), + ) + .count() + ) + if pending > 0: + return # Not all jobs done yet + + logger.info(f"[worker] All jobs done for {student_id} attempt {attempt} — calculating score") + + mid = UUID(module_id) if isinstance(module_id, str) else module_id + + answers = ( + db.query(StudentAnswer) + .filter( + StudentAnswer.student_id == student_id, + StudentAnswer.module_id == mid, + StudentAnswer.attempt == attempt, + ) + .all() + ) + + total_points_possible = 0.0 + total_points_earned = 0.0 + + for answer in answers: + question = db.query(Question).filter(Question.id == answer.question_id).first() + if question: + total_points_possible += question.points + + feedback = db.query(AIFeedback).filter(AIFeedback.answer_id == answer.id).first() + if feedback and feedback.points_earned is not None: + total_points_earned += feedback.points_earned + + percentage_score = ( + (total_points_earned / total_points_possible * 100) if total_points_possible > 0 else 0 + ) + + submission = ( + db.query(TestSubmission) + .filter( + TestSubmission.student_id == student_id, + TestSubmission.module_id == mid, + TestSubmission.attempt == attempt, + ) + .first() + ) + + if submission: + submission.total_points_possible = total_points_possible + submission.total_points_earned = total_points_earned + submission.percentage_score = percentage_score + db.commit() + logger.info( + f"[worker] Test score updated: {total_points_earned}/{total_points_possible} " + f"({percentage_score:.1f}%)" + ) + else: + logger.warning(f"[worker] TestSubmission not found for attempt {attempt}") + + except Exception as e: + logger.error(f"[worker] Error calculating test score: {e}") + traceback.print_exc() + db.rollback() + finally: + db.close() diff --git a/Backend/app/services/openai_client.py b/Backend/app/services/openai_client.py index f766c1f..b06a523 100644 --- a/Backend/app/services/openai_client.py +++ b/Backend/app/services/openai_client.py @@ -38,14 +38,14 @@ def __init__(self, api_key: str, default_model: str = "gpt-4"): """ self.client = openai.OpenAI( api_key=api_key, - timeout=90.0, # 90 second timeout per request + timeout=30.0, # 30 second timeout per request max_retries=0 # We handle retries ourselves for better control ) self.default_model = default_model @retry( - stop=stop_after_attempt(3), # Try up to 3 times - wait=wait_exponential(multiplier=1, min=2, max=10), # 2s, 4s, 8s delays + stop=stop_after_attempt(2), # Try up to 2 times (fits within 45s stale window) + wait=wait_exponential(multiplier=1, min=2, max=5), # 2s, then 4s delay retry=retry_if_exception_type(( openai.APITimeoutError, openai.APIConnectionError, @@ -89,7 +89,7 @@ def create_chat_completion( messages=messages, temperature=temperature, max_tokens=max_tokens, - timeout=90.0, # Explicit timeout + timeout=30.0, # Explicit timeout **kwargs ) @@ -97,7 +97,7 @@ def create_chat_completion( return response except openai.APITimeoutError as e: - logger.error(f"⏱️ OpenAI API timeout after 90s: {e}") + logger.error(f"⏱️ OpenAI API timeout after 30s: {e}") raise # Will trigger retry via tenacity except openai.RateLimitError as e: diff --git a/Backend/app/services/prompt_builder.py b/Backend/app/services/prompt_builder.py index 7ef001e..8287912 100644 --- a/Backend/app/services/prompt_builder.py +++ b/Backend/app/services/prompt_builder.py @@ -2,7 +2,7 @@ Dynamic prompt builder for AI feedback Builds prompts based on rubric settings, question type, and RAG context """ -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, List def build_mcq_feedback_prompt( @@ -12,7 +12,8 @@ def build_mcq_feedback_prompt( correct_answer: str, is_correct: Optional[bool], rubric: Dict[str, Any], - rag_context: Optional[Dict[str, Any]] = None + rag_context: Optional[Dict[str, Any]] = None, + previous_feedback: Optional[List[Dict[str, Any]]] = None ) -> str: """ Build prompt for MCQ feedback generation @@ -25,6 +26,7 @@ def build_mcq_feedback_prompt( is_correct: Whether the answer is correct (None if no correct answer set) rubric: Rubric configuration rag_context: Retrieved course material context + previous_feedback: Optional list of previous attempt feedback for progressive feedback Returns: Complete prompt string for AI @@ -80,6 +82,11 @@ def build_mcq_feedback_prompt( prompt_parts.append(rag_context["formatted_context"]) prompt_parts.append("") + # 3b. Previous feedback context for progressive feedback + if previous_feedback: + prompt_parts.append(_build_previous_feedback_section(previous_feedback)) + prompt_parts.append("") + # 4. Grading criteria if grading_criteria: prompt_parts.append("Evaluate the response based on these criteria:") @@ -193,7 +200,8 @@ def build_text_feedback_prompt( student_answer: str, reference_answer: str, rubric: Dict[str, Any], - rag_context: Optional[Dict[str, Any]] = None + rag_context: Optional[Dict[str, Any]] = None, + previous_feedback: Optional[List[Dict[str, Any]]] = None ) -> str: """ Build prompt for text-based (short/essay) feedback generation @@ -205,6 +213,7 @@ def build_text_feedback_prompt( reference_answer: Reference/expected answer rubric: Rubric configuration rag_context: Retrieved course material context + previous_feedback: Optional list of previous attempt feedback for progressive feedback Returns: Complete prompt string for AI @@ -261,6 +270,11 @@ def build_text_feedback_prompt( prompt_parts.append(rag_context["formatted_context"]) prompt_parts.append("") + # 3b. Previous feedback context for progressive feedback + if previous_feedback: + prompt_parts.append(_build_previous_feedback_section(previous_feedback)) + prompt_parts.append("") + # 4. Grading criteria if grading_criteria: prompt_parts.append("Evaluate the response based on these criteria:") @@ -379,6 +393,48 @@ def build_text_feedback_prompt( return "\n".join(prompt_parts) +def _build_previous_feedback_section(previous_feedback: List[Dict[str, Any]]) -> str: + """ + Build a prompt section describing previous attempts' feedback for progressive feedback. + + Args: + previous_feedback: List of dicts with keys: attempt, ai_feedback, score, student_answer + + Returns: + Formatted prompt section string + """ + lines = [] + lines.append("=" * 60) + lines.append("📚 PREVIOUS ATTEMPTS CONTEXT (for progressive feedback)") + lines.append("=" * 60) + lines.append("The student has attempted this question before. Below is their history:") + lines.append("") + + for entry in sorted(previous_feedback, key=lambda x: x.get("attempt", 0)): + attempt_num = entry.get("attempt", "?") + student_ans = entry.get("student_answer", "N/A") + score = entry.get("score") + ai_fb = entry.get("ai_feedback", "No feedback available") + + lines.append(f"--- Attempt {attempt_num} ---") + lines.append(f"Student Answer: {student_ans}") + if score is not None: + lines.append(f"Score: {score}%") + lines.append(f"Feedback Given: {ai_fb}") + lines.append("") + + lines.append("⚠️ PROGRESSIVE FEEDBACK INSTRUCTIONS:") + lines.append("- DO NOT repeat feedback that was already given in previous attempts") + lines.append("- BUILD UPON prior feedback — go deeper, address what the student still hasn't grasped") + lines.append("- If the student made the SAME mistake again, address the persistent misconception directly") + lines.append("- If the student IMPROVED, acknowledge the improvement and guide them further") + lines.append("- Provide NEW insights, hints, or explanations not covered in previous feedback") + lines.append("- Reference what was said before only briefly (e.g., 'As noted previously...') to add new depth") + lines.append("=" * 60) + + return "\n".join(lines) + + def format_grading_criteria(criteria: Dict[str, Any]) -> str: """ Format grading criteria for display in prompts diff --git a/Backend/app/services/rag_retriever.py b/Backend/app/services/rag_retriever.py index 6c8b0a5..46fd6b4 100644 --- a/Backend/app/services/rag_retriever.py +++ b/Backend/app/services/rag_retriever.py @@ -2,12 +2,51 @@ RAG (Retrieval-Augmented Generation) retrieval service Fetches relevant course material context for AI feedback generation """ +import hashlib +import time +import threading +import logging from typing import List, Dict, Any, Optional from sqlalchemy.orm import Session from app.models.document import Document from app.services.embedding import search_similar_chunks +logger = logging.getLogger(__name__) + +# In-memory cache for RAG results keyed by (module_id, question_text). +# Course materials don't change between students, so the 2nd student +# answering the same question gets instant context. +# TTL = 30 minutes (documents rarely change mid-session). +_rag_cache: Dict[str, Dict[str, Any]] = {} +_rag_cache_lock = threading.Lock() +_RAG_CACHE_TTL = 1800 # 30 minutes + + +def _cache_key(module_id: str, question_text: str) -> str: + """Stable cache key from module + question text.""" + raw = f"{module_id}:{question_text}" + return hashlib.sha256(raw.encode()).hexdigest() + + +def _get_cached(key: str) -> Optional[Dict[str, Any]]: + with _rag_cache_lock: + entry = _rag_cache.get(key) + if entry and (time.time() - entry["ts"]) < _RAG_CACHE_TTL: + return entry["data"] + if entry: + del _rag_cache[key] + return None + + +def _set_cached(key: str, data: Dict[str, Any]): + with _rag_cache_lock: + # Evict oldest entries if cache grows beyond 500 questions + if len(_rag_cache) >= 500: + oldest_key = min(_rag_cache, key=lambda k: _rag_cache[k]["ts"]) + del _rag_cache[oldest_key] + _rag_cache[key] = {"data": data, "ts": time.time()} + def get_context_for_feedback( db: Session, @@ -15,11 +54,13 @@ def get_context_for_feedback( student_answer: str, module_id: str, max_chunks: int = 3, - similarity_threshold: float = 0.7, + similarity_threshold: float = 0.3, include_document_locations: bool = True ) -> Dict[str, Any]: """ - Retrieve relevant course material context for feedback generation + Retrieve relevant course material context for feedback generation. + Results are cached per (module_id, question_text) so repeated lookups + for the same question across students are instant. Args: db: Database session @@ -37,6 +78,13 @@ def get_context_for_feedback( 'sources': List[str] # Document sources for citations } """ + # Check cache first — keyed on module + question only (not student answer) + key = _cache_key(str(module_id), question_text) + cached = _get_cached(key) + if cached is not None: + logger.info(f"RAG CACHE HIT for module {module_id}") + return cached + # Combine question and answer for better context matching query = f"Question: {question_text}\nAnswer: {student_answer}" @@ -51,18 +99,19 @@ def get_context_for_feedback( print(f" Found {len(documents)} embedded documents") if not documents: - # Debug: Check what documents exist (regardless of status) all_docs = db.query(Document).filter(Document.module_id == module_id).all() - print(f" Total documents in module: {len(all_docs)}") + logger.info(f" Total documents in module: {len(all_docs)}") for doc in all_docs: - print(f" - {doc.title}: status={doc.processing_status}, is_testbank={doc.is_testbank}") + logger.info(f" - {doc.title}: status={doc.processing_status}, is_testbank={doc.is_testbank}") - return { + no_ctx = { 'has_context': False, 'chunks': [], 'formatted_context': '', 'sources': [] } + _set_cached(key, no_ctx) + return no_ctx # Search across all module documents all_results = [] @@ -80,10 +129,10 @@ def get_context_for_feedback( result['document_id'] = str(doc.id) all_results.extend(results) except Exception as e: - print(f"Error searching document {doc.id}: {str(e)}") + logger.error(f"Error searching document {doc.id}: {str(e)}") continue - print(f" Retrieved {len(all_results)} total chunks from {len(documents)} documents") + logger.info(f" Retrieved {len(all_results)} total chunks from {len(documents)} documents") # Filter by similarity threshold filtered_results = [ @@ -91,24 +140,31 @@ def get_context_for_feedback( if r['similarity'] >= similarity_threshold ] - print(f" After filtering (threshold={similarity_threshold}): {len(filtered_results)} chunks") + logger.info(f" After filtering (threshold={similarity_threshold}): {len(filtered_results)} chunks") + + # BEST-EFFORT FALLBACK: If threshold filtering removed all results but we + # DO have embedded documents, use the top results anyway. Course material + # is almost always relevant to questions from the same module — the + # similarity score just isn't high enough for the threshold. if all_results and not filtered_results: - # Show top similarity scores to help debug threshold issues - top_3 = sorted(all_results, key=lambda x: x['similarity'], reverse=True)[:3] - top_scores = [f"{r['similarity']:.3f}" for r in top_3] - print(f" Top 3 similarity scores: {top_scores}") + all_results.sort(key=lambda x: x['similarity'], reverse=True) + filtered_results = all_results[:max_chunks] + top_scores = [f"{r['similarity']:.3f}" for r in filtered_results] + logger.info(f" Best-effort fallback: using top {len(filtered_results)} chunks (scores: {top_scores})") # Sort by similarity and get top N filtered_results.sort(key=lambda x: x['similarity'], reverse=True) top_results = filtered_results[:max_chunks] if not top_results: - return { + no_ctx = { 'has_context': False, 'chunks': [], 'formatted_context': '', 'sources': [] } + _set_cached(key, no_ctx) + return no_ctx # Format context for prompt formatted_context = format_context_for_prompt(top_results, include_document_locations) @@ -119,12 +175,15 @@ def get_context_for_feedback( for chunk in top_results ])) - return { + result = { 'has_context': True, 'chunks': top_results, 'formatted_context': formatted_context, 'sources': sources } + _set_cached(key, result) + logger.info(f"RAG CACHE SET for module {module_id} ({len(top_results)} chunks)") + return result diff --git a/Backend/main.py b/Backend/main.py index 73bd254..74384f5 100644 --- a/Backend/main.py +++ b/Backend/main.py @@ -87,9 +87,19 @@ def on_startup(): # ✅ Ensure all models are imported for table creation from app.models import user, document, question, module, student_answer, student_enrollment, survey_response, question_queue, document_chunk, document_embedding, ai_feedback, chat_conversation, chat_message + from app.models import feedback_job # noqa: F401 — register FeedbackJob table print("📊 Creating database tables...") Base.metadata.create_all(bind=engine) - print("✅ All tables created successfully (including student_enrollments, survey_responses, ai_feedback and chat tables)") + print("✅ All tables created successfully") + + # ✅ Recover any jobs that were in-flight when the server last stopped + from app.services.feedback_worker import recover_stale_jobs, start_worker + recover_stale_jobs() + print("✅ Stale feedback jobs recovered") + + # ✅ Start the feedback worker thread + start_worker() + print("✅ Feedback worker started") print("🎉 Application startup complete!") # 📎 Test route @@ -100,4 +110,115 @@ def read_root(): # 📎 Sample item route @app.get("/items/{item_id}") def read_item(item_id: int, q: Union[str, None] = None): - return {"item_id": item_id, "q": q} \ No newline at end of file + return {"item_id": item_id, "q": q} + +# 🩺 Diagnostic endpoint to verify OpenAI + DB health +@app.get("/api/health/diagnostics") +def run_diagnostics(): + """Quick health check of OpenAI API, database, and feedback stats.""" + import time + from app.database import SessionLocal + from app.core.config import OPENAI_API_KEY, LLM_MODEL + + results = {"openai": {}, "database": {}, "feedback_stats": {}, "job_queue": {}} + + # 1. Test OpenAI API + try: + import json as _json + from app.services.openai_client import OpenAIClientWithRetry + client = OpenAIClientWithRetry(api_key=OPENAI_API_KEY, default_model=LLM_MODEL) + + # Test gpt-4o-mini (used for MCQ grading) + start = time.time() + resp = client.create_chat_completion( + messages=[{"role": "user", "content": "Say OK"}], + model="gpt-4o-mini", max_tokens=5 + ) + results["openai"]["gpt-4o-mini"] = { + "status": "ok", + "response_time_ms": int((time.time() - start) * 1000), + "response": resp.choices[0].message.content.strip() + } + + # Test default model (used for text/essay grading) + start = time.time() + resp = client.create_chat_completion( + messages=[{"role": "user", "content": "Say OK"}], + model=LLM_MODEL, max_tokens=5 + ) + results["openai"][LLM_MODEL] = { + "status": "ok", + "response_time_ms": int((time.time() - start) * 1000), + "response": resp.choices[0].message.content.strip() + } + + # Test JSON structured output parsing (the actual failure mode) + start = time.time() + json_resp = client.create_chat_completion( + messages=[{"role": "user", "content": 'Respond with only this JSON: {"is_correct": true, "explanation": "test"}'}], + model="gpt-4o-mini", max_tokens=50, temperature=0.0 + ) + raw_json = json_resp.choices[0].message.content.strip() + # Try parsing — this is where real feedback often fails + if raw_json.startswith("```"): + raw_json = raw_json.split("```")[1] + if raw_json.startswith("json"): + raw_json = raw_json[4:] + raw_json = raw_json.strip() + parsed = _json.loads(raw_json) + results["openai"]["json_parse_test"] = { + "status": "ok", + "response_time_ms": int((time.time() - start) * 1000), + "raw_response": json_resp.choices[0].message.content.strip()[:200], + "parsed_keys": list(parsed.keys()) + } + except _json.JSONDecodeError as je: + results["openai"]["json_parse_test"] = { + "status": "fail", + "error_type": "JSONDecodeError", + "error": str(je)[:200], + "raw_response": raw_json[:200] if 'raw_json' in dir() else None + } + except Exception as e: + results["openai"]["error"] = f"{type(e).__name__}: {str(e)}" + + # 2. Test database + db = SessionLocal() + try: + from app.models.ai_feedback import AIFeedback + from sqlalchemy import func + + total = db.query(func.count(AIFeedback.id)).scalar() + statuses = dict( + db.query(AIFeedback.generation_status, func.count()) + .group_by(AIFeedback.generation_status).all() + ) + + # Count fallback feedbacks + completed = db.query(AIFeedback).filter( + AIFeedback.generation_status == 'completed' + ).all() + fallback_count = sum( + 1 for fb in completed + if fb.feedback_data and fb.feedback_data.get('fallback', False) + ) + + results["database"]["status"] = "ok" + results["feedback_stats"] = { + "total": total, + "by_status": statuses, + "fallback_stuck_as_completed": fallback_count + } + except Exception as e: + results["database"]["error"] = f"{type(e).__name__}: {str(e)}" + finally: + db.close() + + # 3. Job queue stats + try: + from app.services.feedback_worker import get_queue_stats + results["job_queue"] = get_queue_stats() + except Exception as e: + results["job_queue"]["error"] = f"{type(e).__name__}: {str(e)}" + + return results \ No newline at end of file diff --git a/Frontend/app/dashboard/page.js b/Frontend/app/dashboard/page.js index 40cfcc8..783fde2 100644 --- a/Frontend/app/dashboard/page.js +++ b/Frontend/app/dashboard/page.js @@ -123,11 +123,11 @@ const DashboardContent = memo(function DashboardContent() { }, []); // Load recent activities - const loadRecentActivities = useCallback(async (moduleId) => { + const loadRecentActivities = useCallback(async (moduleId, prefetchedAnswers = null) => { setLoadingActivities(true); try { // Get recent student answers - const answersData = await apiClient.get(`/api/student-answers/?module_id=${moduleId}`); + const answersData = prefetchedAnswers ?? await apiClient.get(`/api/student-answers/?module_id=${moduleId}`); if (Array.isArray(answersData)) { // Sort by timestamp and take last 10 @@ -154,10 +154,10 @@ const DashboardContent = memo(function DashboardContent() { }, []); // Load action items (things needing attention) - const loadActionItems = useCallback(async (moduleId) => { + const loadActionItems = useCallback(async (moduleId, prefetchedAnswers = null) => { setLoadingActions(true); try { - const answersData = await apiClient.get(`/api/student-answers/?module_id=${moduleId}`); + const answersData = prefetchedAnswers ?? await apiClient.get(`/api/student-answers/?module_id=${moduleId}`); if (Array.isArray(answersData)) { // Count submissions that need manual grading (marked for teacher review) @@ -200,10 +200,10 @@ const DashboardContent = memo(function DashboardContent() { }, [moduleData.totalStudents]); // Load performance metrics - const loadPerformanceMetrics = useCallback(async (moduleId) => { + const loadPerformanceMetrics = useCallback(async (moduleId, prefetchedAnswers = null) => { setLoadingMetrics(true); try { - const answersData = await apiClient.get(`/api/student-answers/?module_id=${moduleId}`); + const answersData = prefetchedAnswers ?? await apiClient.get(`/api/student-answers/?module_id=${moduleId}`); if (Array.isArray(answersData) && answersData.length > 0) { // Calculate average score @@ -237,9 +237,9 @@ const DashboardContent = memo(function DashboardContent() { }, [moduleData.totalStudents]); // Load progress data for chart - const loadProgressData = useCallback(async (moduleId) => { + const loadProgressData = useCallback(async (moduleId, prefetchedAnswers = null) => { try { - const answersData = await apiClient.get(`/api/student-answers/?module_id=${moduleId}`); + const answersData = prefetchedAnswers ?? await apiClient.get(`/api/student-answers/?module_id=${moduleId}`); if (Array.isArray(answersData) && answersData.length > 0) { // Group submissions by date (last 7 days) @@ -308,9 +308,9 @@ const DashboardContent = memo(function DashboardContent() { }, []); // Load score distribution for chart - const loadScoreDistribution = useCallback(async (moduleId) => { + const loadScoreDistribution = useCallback(async (moduleId, prefetchedAnswers = null) => { try { - const answersData = await apiClient.get(`/api/student-answers/?module_id=${moduleId}`); + const answersData = prefetchedAnswers ?? await apiClient.get(`/api/student-answers/?module_id=${moduleId}`); if (Array.isArray(answersData) && answersData.length > 0) { // Filter answers with scores @@ -357,6 +357,9 @@ const DashboardContent = memo(function DashboardContent() { } setLoadingModuleData(true); + setLoadingActivities(true); + setLoadingActions(true); + setLoadingMetrics(true); try { const modules = await apiClient.get(`/api/modules?teacher_id=${userId}`); const currentModule = modules.find(m => m.name === moduleName); @@ -364,58 +367,85 @@ const DashboardContent = memo(function DashboardContent() { if (currentModule) { setModuleId(currentModule.id); - // Load real data + // Single batched call — backend computes everything and caches for 30s try { - // Get students count (from student-answers endpoint - count unique students) - let studentsCount = 0; - try { - const answersData = await apiClient.get(`/api/student-answers/?module_id=${currentModule.id}`); - if (Array.isArray(answersData)) { - const uniqueStudents = new Set(answersData.map(a => a.student_id)); - studentsCount = uniqueStudents.size; - } - } catch (err) { - console.log('No student answers yet'); - } + const metrics = await apiClient.get( + `/api/modules/${currentModule.id}/dashboard-metrics?teacher_id=${userId}` + ); - // Get questions count (all questions for teacher dashboard) - const questionsData = await apiClient.get(`/api/questions/by-module?module_id=${currentModule.id}&status=all`); - const questionsCount = Array.isArray(questionsData) ? questionsData.length : 0; + setModuleData({ + accessCode: metrics.access_code, + totalStudents: metrics.total_students, + totalQuestions: metrics.total_questions, + totalDocuments: metrics.total_documents + }); - // Get documents count (need teacher_id parameter) - const documentsData = await apiClient.get(`/api/documents?teacher_id=${userId}&module_id=${currentModule.id}`); - const documentsCount = Array.isArray(documentsData) ? documentsData.length : 0; + setPerformanceMetrics({ + averageScore: metrics.average_score, + completionRate: metrics.completion_rate, + aiFeedbackCount: metrics.ai_feedback_count, + totalSubmissions: metrics.total_submissions + }); - setModuleData({ - accessCode: currentModule.access_code, - totalStudents: studentsCount, - totalQuestions: questionsCount, - totalDocuments: documentsCount + setActionItems({ + pendingGrades: metrics.action_items.pending_grades, + inactiveStudents: metrics.action_items.inactive_students, + lowPerformanceQuestions: metrics.action_items.low_performance_questions }); + + // Score distribution — add colors client-side + const colors = [ + 'bg-red-500 dark:bg-red-600', + 'bg-orange-500 dark:bg-orange-600', + 'bg-yellow-500 dark:bg-yellow-600', + 'bg-blue-500 dark:bg-blue-600', + 'bg-emerald-500 dark:bg-emerald-600' + ]; + setScoreDistribution( + (metrics.score_distribution || []).map((r, i) => ({ ...r, color: colors[i] })) + ); + + // Activity chart — convert dateKey strings to date objects for chart + setProgressData( + (metrics.activity_chart || []).map(d => ({ + ...d, + date: new Date(d.dateKey) + })) + ); + + // Recent activity + setRecentActivities( + (metrics.recent_activity || []).map(a => ({ + id: a.id, + type: 'submission', + studentName: `Student ${a.student_id}`, + questionText: `Question`, + timestamp: a.timestamp, + score: a.score + })) + ); + + // Rubric summary + if (metrics.rubric_summary) { + setRubricSummary(metrics.rubric_summary); + } } catch (error) { - console.error('Failed to load module stats:', error); + console.error('Failed to load dashboard metrics:', error); setModuleData(prev => ({ ...prev, accessCode: currentModule.access_code })); } - - // Load rubric summary - loadRubricSummary(currentModule.id); - - // Load dynamic dashboard data - loadRecentActivities(currentModule.id); - loadActionItems(currentModule.id); - loadPerformanceMetrics(currentModule.id); - loadProgressData(currentModule.id); - loadScoreDistribution(currentModule.id); } } catch (error) { console.error('Failed to load module data:', error); } finally { setLoadingModuleData(false); + setLoadingActivities(false); + setLoadingActions(false); + setLoadingMetrics(false); } - }, [user, moduleName, loadRubricSummary, loadRecentActivities, loadActionItems, loadPerformanceMetrics, loadProgressData, loadScoreDistribution]); + }, [user, moduleName]); // Load real module data from database useEffect(() => { diff --git a/Frontend/app/globals.css b/Frontend/app/globals.css index 1e18848..0083734 100644 --- a/Frontend/app/globals.css +++ b/Frontend/app/globals.css @@ -1,6 +1,14 @@ @import "tailwindcss"; @import "tw-animate-css"; +@keyframes scroll { + 0% { transform: translateX(0); } + 100% { transform: translateX(-50%); } +} +.animate-scroll { + animation: scroll 30s linear infinite; +} + @custom-variant dark (&:is(.dark *)); @theme inline { diff --git a/Frontend/app/layout.js b/Frontend/app/layout.js index 29f8716..6023656 100644 --- a/Frontend/app/layout.js +++ b/Frontend/app/layout.js @@ -23,9 +23,16 @@ export const metadata = { description: "AI-powered education and learning platform for students and teachers", }; +const apiOrigin = (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000') + .replace(/\/$/, '') + .replace(/^(https?:\/\/[^/]+).*$/, '$1'); + export default function RootLayout({ children }) { return ( + + + diff --git a/Frontend/app/page.js b/Frontend/app/page.js index 3b213b4..1646a45 100644 --- a/Frontend/app/page.js +++ b/Frontend/app/page.js @@ -1,8 +1,10 @@ "use client"; import { useAuth } from "@/context/AuthContext"; -import DashboardPage from "@/components/dashboard/DashboardPage"; -import LandingPage from "@/components/landing/LandingPage"; +import dynamic from "next/dynamic"; + +const DashboardPage = dynamic(() => import("@/components/dashboard/DashboardPage")); +const LandingPage = dynamic(() => import("@/components/landing/LandingPage")); export default function Home() { const { user, loading, isAuthenticated } = useAuth(); diff --git a/Frontend/app/student/module/[moduleId]/page.js b/Frontend/app/student/module/[moduleId]/page.js index dde5266..692842b 100644 --- a/Frontend/app/student/module/[moduleId]/page.js +++ b/Frontend/app/student/module/[moduleId]/page.js @@ -103,6 +103,8 @@ const StudentModuleContent = memo(function StudentModuleContent() { const [feedbackStatus, setFeedbackStatus] = useState(null); // Track real-time feedback generation status const [isPolling, setIsPolling] = useState(false); // Track if we're actively polling const [pollCount, setPollCount] = useState(0); // Track number of polling attempts + const [useSSE, setUseSSE] = useState(true); // Try SSE first, fall back to polling + const eventSourceRef = useRef(null); // Ref for SSE EventSource connection const [answeredQuestions, setAnsweredQuestions] = useState({}); // Track which questions were answered const [expandedRubrics, setExpandedRubrics] = useState({}); // Track which rubric breakdowns are expanded const [hasTeacherGrades, setHasTeacherGrades] = useState(false); // Track if teacher has graded any work @@ -514,7 +516,7 @@ const StudentModuleContent = memo(function StudentModuleContent() { }, [moduleId, loadFeedbackForAnswers]); const loadModuleContent = useCallback(async (access, retryCount = 0) => { - const MAX_RETRIES = 5; // Maximum number of automatic retries + const MAX_RETRIES = 2; // Page-level retries (apiClient already retries internally) try { setLoading(true); @@ -532,7 +534,8 @@ const StudentModuleContent = memo(function StudentModuleContent() { apiClient.get(`/api/student/modules/${moduleId}/questions`) ]); } catch (error) { - // If core data fails due to connection issue, retry automatically + // If core data fails due to connection issue, retry at page level + // (apiClient already retried 2-3 times internally, so this is a last resort) const isConnectionError = error.message?.includes('fetch') || error.message?.includes('timeout') || error.message?.includes('AbortError') || @@ -540,17 +543,14 @@ const StudentModuleContent = memo(function StudentModuleContent() { if (isConnectionError) { if (retryCount < MAX_RETRIES) { - console.log(`Connection issue (attempt ${retryCount + 1}/${MAX_RETRIES}), retrying in 2 seconds...`, error.message); - // DON'T set loading to false - keep skeleton visible during retry - // DON'T set error - we're still trying + const delay = 3000 * (retryCount + 1); // 3s, 6s + console.log(`Connection issue (page retry ${retryCount + 1}/${MAX_RETRIES}), retrying in ${delay / 1000}s...`, error.message); setTimeout(() => { - console.log('Retrying module load...'); loadModuleContent(access, retryCount + 1); - }, 2000); - return; // Return WITHOUT throwing - keeps loading state active + }, delay); + return; } else { console.error(`Max retries (${MAX_RETRIES}) reached, giving up`); - // After max retries, show error page with retry button throw { message: 'Unable to connect to the server after multiple attempts. Please check your connection and try again.', canRetry: true, @@ -760,47 +760,114 @@ const StudentModuleContent = memo(function StudentModuleContent() { loadFeedbackForAnswersRef.current = loadFeedbackForAnswers; }, [checkFeedbackStatus, loadFeedbackForAnswers]); - // Effect to handle polling when feedback is being generated + // Effect to handle feedback updates — SSE first, polling fallback useEffect(() => { if (!isPolling || !moduleAccess || !submissionStatus) { - console.log('⏸️ Polling paused:', { isPolling, hasAccess: !!moduleAccess, hasStatus: !!submissionStatus }); + console.log('⏸️ Feedback monitoring paused:', { isPolling, hasAccess: !!moduleAccess, hasStatus: !!submissionStatus }); return; } - const MAX_POLLS = 180; // Stop after 180 polls (6 minutes at 2s intervals) + const MAX_POLL_TIME = 6 * 60 * 1000; // Stop after 6 minutes total const currentAttempt = submissionStatus.current_attempt || 1; + const attemptToWatch = currentAttempt - 1; + let stopped = false; - console.log(`🚀 =====================================`); - console.log(`🚀 POLLING STARTED`); - console.log(`🚀 Attempt to monitor: ${currentAttempt - 1}`); - console.log(`🚀 Module ID: ${moduleId}`); - console.log(`🚀 Student ID: ${moduleAccess.studentId}`); - console.log(`🚀 =====================================`); + // ── SSE path ── + if (useSSE) { + const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; + const sseUrl = `${API_BASE_URL}/api/ai-feedback/stream/${moduleId}?student_id=${encodeURIComponent(moduleAccess.studentId)}&attempt=${attemptToWatch}`; + + console.log(`📡 SSE connecting: ${sseUrl}`); + const es = new EventSource(sseUrl); + eventSourceRef.current = es; + + es.addEventListener('progress', async (e) => { + if (stopped) return; + try { + const data = JSON.parse(e.data); + console.log(`📡 SSE progress: ${data.ready}/${data.total} (${data.percentage}%)`); + if (stopped) return; + setFeedbackStatus(prev => ({ ...prev, ...data, feedback_ready: data.ready, total_questions: data.total, progress_percentage: data.percentage })); + setPollCount(prev => prev + 1); + // Reload feedback to show newly completed items + await loadFeedbackForAnswersRef.current(moduleAccess); + } catch (err) { + if (!stopped) console.warn('SSE progress parse error:', err); + } + }); + + es.addEventListener('complete', async (e) => { + if (stopped) return; + try { + const data = JSON.parse(e.data); + console.log(`🎉 SSE complete: ${data.ready}/${data.total}`); + if (stopped) return; + setFeedbackStatus(prev => ({ ...prev, all_complete: true, feedback_ready: data.ready, total_questions: data.total })); + await loadFeedbackForAnswersRef.current(moduleAccess); + if (stopped) return; + setIsPolling(false); + setPollCount(0); + } catch (err) { + if (!stopped) console.warn('SSE complete parse error:', err); + } + es.close(); + eventSourceRef.current = null; + }); + + es.addEventListener('timeout', (e) => { + console.log('⏱️ SSE timeout — server closed stream'); + es.close(); + eventSourceRef.current = null; + setIsPolling(false); + }); + + es.onerror = (err) => { + if (stopped) return; + console.warn('⚠️ SSE error — falling back to polling', err); + es.close(); + eventSourceRef.current = null; + // Fall back to polling + setUseSSE(false); + // isPolling stays true, so the effect re-runs with useSSE=false + }; + + return () => { + console.log('🛑 Closing SSE connection'); + stopped = true; + es.close(); + eventSourceRef.current = null; + setPollCount(0); + }; + } + + // ── Polling fallback ── + console.log(`🚀 POLLING STARTED (fallback) — attempt ${attemptToWatch}, module ${moduleId}`); let pollCount = 0; - let pollInterval = null; + let pollStart = Date.now(); + let timeoutId = null; + + const getDelay = () => { + const elapsed = Date.now() - pollStart; + if (elapsed < 30000) return 2000; + if (elapsed < 150000) return 5000; + return 10000; + }; - // Polling function - uses latest callbacks via refs const doPoll = async () => { + if (stopped) return; pollCount++; - const timestamp = new Date().toLocaleTimeString(); - console.log(`\n🔄 ========== Poll #${pollCount}/${MAX_POLLS} at ${timestamp} ==========`); + const elapsed = Date.now() - pollStart; - setPollCount(pollCount); // Update UI counter + setPollCount(pollCount); - // Stop polling after max attempts - if (pollCount >= MAX_POLLS) { - console.log('⏱️ Polling timeout - stopping after 6 minutes'); - if (pollInterval) clearInterval(pollInterval); + if (elapsed >= MAX_POLL_TIME) { + console.log('⏱️ Polling timeout — stopping after 6 minutes'); setIsPolling(false); - - // Trigger cleanup for stale feedback try { - console.log('🧹 Triggering cleanup for stale feedback...'); await apiClient.post( `/api/student/modules/${moduleId}/cleanup-feedback?student_id=${moduleAccess.studentId}` ); - console.log('✅ Cleanup completed, reloading feedback...'); await loadFeedbackForAnswersRef.current(moduleAccess); } catch (error) { console.error('Failed to cleanup stale feedback:', error); @@ -808,42 +875,33 @@ const StudentModuleContent = memo(function StudentModuleContent() { return; } - // Check feedback status and load new feedback using latest callback via ref try { - console.log(`📞 Calling checkFeedbackStatus (via ref)...`); - const allComplete = await checkFeedbackStatusRef.current(moduleAccess, currentAttempt - 1); + const allComplete = await checkFeedbackStatusRef.current(moduleAccess, attemptToWatch); if (allComplete) { - console.log(`🎉 ========== ALL FEEDBACK COMPLETE! ==========`); - if (pollInterval) clearInterval(pollInterval); + console.log('🎉 ALL FEEDBACK COMPLETE!'); setIsPolling(false); setPollCount(0); - } else { - console.log(`⏳ Still generating... (${pollCount}/${MAX_POLLS})`); + return; } } catch (error) { console.log(`⚠️ Poll #${pollCount} failed (will retry):`, error.message); - // Don't stop polling on errors, just continue } - }; - // Start polling immediately, then every 2 seconds - console.log('▶️ Executing first poll NOW...'); - doPoll(); // First poll happens immediately + const delay = getDelay(); + console.log(`⏳ Poll #${pollCount} done — next in ${delay / 1000}s`); + timeoutId = setTimeout(doPoll, delay); + }; - pollInterval = setInterval(() => { - doPoll(); - }, 2000); // Then poll every 2 seconds + doPoll(); - // Cleanup function return () => { - console.log('🛑 ===== STOPPING POLLING INTERVAL ====='); - if (pollInterval) { - clearInterval(pollInterval); - } + console.log('🛑 STOPPING POLLING'); + stopped = true; + if (timeoutId) clearTimeout(timeoutId); setPollCount(0); }; - }, [isPolling, moduleAccess, submissionStatus, moduleId]); // Minimal deps to avoid recreating interval + }, [isPolling, moduleAccess, submissionStatus, moduleId, useSSE]); // useSSE added to re-run on fallback const handleStartTest = () => { router.push(`/student/test/${moduleId}`); @@ -868,8 +926,12 @@ const StudentModuleContent = memo(function StudentModuleContent() { // Reload feedback to show retry buttons await loadFeedbackForAnswers(moduleAccess); - // Stop polling if active + // Stop polling/SSE if active setIsPolling(false); + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } // Show alert with results if (result.total_fixed > 0) { @@ -906,7 +968,8 @@ const StudentModuleContent = memo(function StudentModuleContent() { if (result.success && result.answers_retried > 0) { alert(`${result.message || `Started regenerating ${result.answers_retried} failed/missing feedback. This may take a few minutes. The page will update automatically.`}`); - // Start polling again to show progress + // Reset SSE and start monitoring again + setUseSSE(true); // Try SSE first for the new batch setIsPolling(true); setPollCount(0); } else if (result.success && result.answers_retried === 0) { @@ -1344,12 +1407,12 @@ const StudentModuleContent = memo(function StudentModuleContent() {

{(() => { const questionTypeCounts = [ - { type: 'mcq', label: 'Multiple Choice', color: 'bg-green-500', count: questions.filter(q => q.type === 'mcq').length }, - { type: 'mcq_multiple', label: 'MCQ (Multiple)', color: 'bg-emerald-500', count: questions.filter(q => q.type === 'mcq_multiple').length }, - { type: 'short', label: 'Short Answer', color: 'bg-yellow-500', count: questions.filter(q => q.type === 'short').length }, - { type: 'long', label: 'Long Answer', color: 'bg-purple-500', count: questions.filter(q => q.type === 'long').length }, - { type: 'fill_blank', label: 'Fill in Blanks', color: 'bg-blue-500', count: questions.filter(q => q.type === 'fill_blank').length }, - { type: 'multi_part', label: 'Multi-Part', color: 'bg-pink-500', count: questions.filter(q => q.type === 'multi_part').length } + { type: 'mcq', label: 'Multiple Choice', color: 'bg-green-500', count: questions.filter(q => q?.type === 'mcq').length }, + { type: 'mcq_multiple', label: 'MCQ (Multiple)', color: 'bg-emerald-500', count: questions.filter(q => q?.type === 'mcq_multiple').length }, + { type: 'short', label: 'Short Answer', color: 'bg-yellow-500', count: questions.filter(q => q?.type === 'short').length }, + { type: 'long', label: 'Long Answer', color: 'bg-purple-500', count: questions.filter(q => q?.type === 'long').length }, + { type: 'fill_blank', label: 'Fill in Blanks', color: 'bg-blue-500', count: questions.filter(q => q?.type === 'fill_blank').length }, + { type: 'multi_part', label: 'Multi-Part', color: 'bg-pink-500', count: questions.filter(q => q?.type === 'multi_part').length } ]; return questionTypeCounts @@ -1779,7 +1842,8 @@ const StudentModuleContent = memo(function StudentModuleContent() { const allAttemptsDone = submissionStatus?.all_attempts_done || false; const currentAttempt = submissionStatus?.current_attempt || 1; const maxAttempts = submissionStatus?.max_attempts || 2; - const latestAttemptWithFeedback = Math.max(...Object.keys(feedbackByAttempt).map(Number)); + const attemptKeys = Object.keys(feedbackByAttempt).map(Number); + const latestAttemptWithFeedback = attemptKeys.length > 0 ? Math.max(...attemptKeys) : 0; // Only show button if: // 1. There are incorrect answers diff --git a/Frontend/app/student/test/[moduleId]/page.js b/Frontend/app/student/test/[moduleId]/page.js index 35a43d0..bf0ced7 100644 --- a/Frontend/app/student/test/[moduleId]/page.js +++ b/Frontend/app/student/test/[moduleId]/page.js @@ -720,9 +720,28 @@ const StudentTestPage = memo(function StudentTestPage() { // Small delay to ensure database commits are complete await new Promise(resolve => setTimeout(resolve, 500)); + // Build previous feedback context for attempt 2+ so the backend AI + // can give progressive, non-repetitive feedback referencing past attempts + let previousFeedbackContext = null; + if (currentAttempt >= 2 && previousAttempts.length > 0) { + previousFeedbackContext = previousAttempts + .filter(pa => pa.attemptNumber < currentAttempt) + .map(pa => ({ + attempt: pa.attemptNumber, + feedback: Object.entries(pa.feedback).map(([questionId, fb]) => ({ + question_id: questionId, + ai_feedback: fb.ai_feedback || fb.feedback_text || null, + score: fb.score ?? null, + student_answer: pa.answers[questionId]?.value ?? null, + })), + })); + console.log(`📚 Sending previous feedback context for ${previousFeedbackContext.length} attempt(s)`); + } + // Use the new batch submission endpoint const response = await apiClient.post( - `/api/student/modules/${moduleId}/submit-test?student_id=${moduleAccess.studentId}&attempt=${currentAttempt}` + `/api/student/modules/${moduleId}/submit-test?student_id=${moduleAccess.studentId}&attempt=${currentAttempt}`, + previousFeedbackContext ? { previous_feedback_context: previousFeedbackContext } : undefined ); const result = response?.data || response || {}; diff --git a/Frontend/components/landing/AnimationWrapper.jsx b/Frontend/components/landing/AnimationWrapper.jsx index f8a25ce..8a5e36c 100644 --- a/Frontend/components/landing/AnimationWrapper.jsx +++ b/Frontend/components/landing/AnimationWrapper.jsx @@ -113,7 +113,7 @@ export function Parallax({ children, speed = 0.5, className = '' }) { } }; - window.addEventListener('scroll', handleScroll); + window.addEventListener('scroll', handleScroll, { passive: true }); return () => window.removeEventListener('scroll', handleScroll); }, [speed]); diff --git a/Frontend/components/landing/BrandSection.jsx b/Frontend/components/landing/BrandSection.jsx index 00bec5e..9b7ef34 100644 --- a/Frontend/components/landing/BrandSection.jsx +++ b/Frontend/components/landing/BrandSection.jsx @@ -20,9 +20,9 @@ const stats = [ export default function BrandSection() { return ( -
-
- +
+
+ {/* Section Header */}

@@ -33,9 +33,9 @@ export default function BrandSection() { {/* Logo Carousel - Pro Level Infinite Scroll Effect */}

{/* Fading Edges Mask */} -
-
- +
+
+
{/* Duplicating brands for seamless loop */} {[...brands, ...brands].map((brand, index) => ( @@ -61,7 +61,7 @@ export default function BrandSection() {
{idx + 1}
- +
{stat.value}
@@ -77,16 +77,6 @@ export default function BrandSection() {
- {/* Tailwind Animation for Scroll (Add to global.css) */} -
); } \ No newline at end of file diff --git a/Frontend/components/landing/FeaturesSection.jsx b/Frontend/components/landing/FeaturesSection.jsx index e447733..1a854cd 100644 --- a/Frontend/components/landing/FeaturesSection.jsx +++ b/Frontend/components/landing/FeaturesSection.jsx @@ -287,7 +287,7 @@ export default function FeaturesSection() { variant="ghost" className="group h-auto p-0 hover:bg-transparent text-slate-900 font-bold text-lg inline-flex items-center" > - + Learn more diff --git a/Frontend/components/landing/Footer.jsx b/Frontend/components/landing/Footer.jsx index a8af8e6..97dda72 100644 --- a/Frontend/components/landing/Footer.jsx +++ b/Frontend/components/landing/Footer.jsx @@ -2,57 +2,24 @@ import { Button } from '@/components/ui/button'; import Link from 'next/link'; -import { Facebook, Instagram, Youtube, Twitter } from 'lucide-react'; -import { FadeIn, SlideInLeft, Float } from './AnimationWrapper'; +import { FadeIn, Float } from './AnimationWrapper'; export default function Footer() { const footerLinks = [ - { - title: 'PLATFORM', - links: [ - { label: 'Module Builder', href: '/modules' }, - { label: 'Assignment Features', href: '/features' }, - { label: 'AI Feedback', href: '/ai-feedback' }, - { label: 'Analytics', href: '/analytics' } - ] - }, - { - title: 'FEATURES', - links: [ - { label: 'AI Analytics', href: '/features#analytics' }, - { label: 'Test Creation', href: '/features#tests' }, - { label: 'Grading', href: '/features#grading' }, - { label: 'Student Dashboard', href: '/features#students' }, - { label: 'Mastery Learning', href: '/features#mastery' }, - ] - }, { title: 'RESOURCES', links: [ + { label: 'User Manual', href: '/user-manual' }, { label: 'Help Center', href: '/help' }, - { label: 'Documentation', href: '/docs' }, - { label: 'Blog', href: '/blog' }, - { label: 'About', href: '/about' } - ] - }, - { - title: 'COMPANY', - links: [ { label: 'About', href: '/about' }, - { label: 'Branding', href: '/branding' }, - { label: 'Affiliates', href: '/affiliates' }, - { label: 'Reviews', href: '/reviews' } ] }, { title: 'SUPPORT', links: [ { label: 'Contact Us', href: '/contact' }, - { label: 'Help center', href: '/help' }, - { label: 'Getting started', href: '/start' }, - { label: 'Hire a Pro', href: '/pros' } ] - } + }, ]; return ( @@ -97,15 +64,15 @@ export default function Footer() { {/* 2. Main Footer Links */}
-
+
{/* Brand Logo */} -
+
ai pilot
- {/* Dynamic Link Columns */} + {/* Link Columns */} {footerLinks.map((column) => (

@@ -129,13 +96,6 @@ export default function Footer() { {/* 3. Bottom Bar */}
-
- - - - -
-
AI Pilot, Inc. © {new Date().getFullYear()}
diff --git a/Frontend/components/landing/TestimonialsSection.jsx b/Frontend/components/landing/TestimonialsSection.jsx index ac59c2f..487bdc5 100644 --- a/Frontend/components/landing/TestimonialsSection.jsx +++ b/Frontend/components/landing/TestimonialsSection.jsx @@ -1,5 +1,6 @@ 'use client'; +import Image from 'next/image'; import { ArrowRight } from 'lucide-react'; import { FadeIn, StaggerContainer, StaggerItem, ScaleIn } from './AnimationWrapper'; @@ -59,10 +60,12 @@ export default function TestimonialsSection() {
{/* Image Container with Custom Podia Border Radius */}
- {t.name} {/* Floating Quote Icon */}
diff --git a/Frontend/lib/auth.js b/Frontend/lib/auth.js index d62fff4..19ea253 100644 --- a/Frontend/lib/auth.js +++ b/Frontend/lib/auth.js @@ -3,77 +3,88 @@ import { jwtDecode } from 'jwt-decode'; // Remove trailing slash from API_BASE_URL to prevent double slashes const API_BASE_URL = (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000').replace(/\/$/, ''); -// Helper function to add timeout to fetch requests with proper abort handling -const fetchWithTimeout = async (url, options = {}, timeout = 30000) => { - // Declare variables outside try block so catch can access them - let timeoutId; - let startTime = Date.now(); - - // CRITICAL: Prevent ANY execution during SSR - // Wrap entire function in try-catch to catch all SSR issues - try { - // Multiple layers of SSR detection - if (typeof window === 'undefined') { - throw new Error('SSR: window undefined'); - } - if (typeof fetch === 'undefined') { - throw new Error('SSR: fetch undefined'); - } - if (typeof AbortController === 'undefined') { - throw new Error('SSR: AbortController undefined'); - } +// Returns true for transient network errors that are worth retrying +const isRetryableError = (error) => { + if (!error) return false; + const msg = error.message || ''; + return ( + error.name === 'AbortError' || + error.name === 'TimeoutError' || + error.isTimeout === true || + error.name === 'TypeError' || + msg.includes('Failed to fetch') || + msg.includes('fetch') || + msg.includes('aborted') || + msg.includes('network') || + msg.includes('ECONNREFUSED') || + msg.includes('ECONNRESET') || + msg.includes('timed out') || + msg.includes('timeout') || + msg.includes('NetworkError') + ); +}; - // Extra check for document (only exists in browser) - if (typeof document === 'undefined') { - throw new Error('SSR: document undefined'); - } +// Single fetch attempt with timeout +const fetchOnce = async (url, options = {}, timeout = 30000) => { + // Prevent execution during SSR + if (typeof window === 'undefined' || typeof document === 'undefined') { + throw new Error('SSR: not in browser'); + } - const controller = new AbortController(); - timeoutId = setTimeout(() => controller.abort(), timeout); + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(new DOMException(`Request timed out after ${timeout}ms`, 'TimeoutError')); + }, timeout); + try { const response = await fetch(url, { ...options, signal: controller.signal, - // Ensure we're not caching failed requests cache: 'no-store', }); - - if (timeoutId) clearTimeout(timeoutId); + clearTimeout(timeoutId); return response; } catch (error) { - if (timeoutId) clearTimeout(timeoutId); - const elapsedTime = Date.now() - startTime; + clearTimeout(timeoutId); + // Wrap AbortError with a clearer message when it was caused by our timeout + if (error.name === 'AbortError') { + const timeoutErr = new Error(`Request timed out after ${Math.round(timeout / 1000)}s`); + timeoutErr.name = 'TimeoutError'; + timeoutErr.isTimeout = true; + throw timeoutErr; + } + throw error; + } +}; - // Handle SSR errors gracefully - don't crash the app - if (error.message?.includes('SSR:')) { - // Silent fail during SSR - this is expected - if (process.env.NODE_ENV === 'development') { - console.log('[fetchWithTimeout] SSR detected, skipping fetch'); +// Fetch with timeout + automatic retry for transient network errors +const fetchWithTimeout = async (url, options = {}, timeout = 30000, maxRetries = 2) => { + let lastError; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fetchOnce(url, options, timeout); + } catch (error) { + lastError = error; + + // Don't retry SSR errors or non-retryable errors + if (error.message?.includes('SSR:') || !isRetryableError(error)) { + throw error; } - throw error; // Let the calling code handle it - } - // Log error details (only in development) - if (process.env.NODE_ENV === 'development') { - console.log('[fetchWithTimeout] Error:', { - url: url.replace(API_BASE_URL, ''), - error: error.message, - type: error.name, - time: `${elapsedTime}ms` - }); - } + // Don't retry if we've exhausted attempts + if (attempt >= maxRetries) break; - // Handle abort/timeout errors - if (error.name === 'AbortError' || error.message?.includes('aborted')) { + // Exponential backoff: 800ms, 2000ms + const delay = Math.min(800 * Math.pow(2.5, attempt), 5000); if (process.env.NODE_ENV === 'development') { - console.warn(`[fetchWithTimeout] Timeout after ${elapsedTime}ms`); + console.log(`[fetch] Retry ${attempt + 1}/${maxRetries} in ${delay}ms — ${url.replace(API_BASE_URL, '')}`); } - throw error; + await new Promise(r => setTimeout(r, delay)); } - - // For all other errors, re-throw them - throw error; } + + throw lastError; }; // Auth functions @@ -432,7 +443,7 @@ export const auth = { } }; -// API helper with authentication and auto-refresh +// API helper with authentication, auto-refresh, and retry export const apiClient = { async request(endpoint, options = {}) { let token = auth.getToken(); @@ -464,55 +475,97 @@ export const apiClient = { ...options, }; - // Dynamic timeout based on endpoint type + // Dynamic timeout and retries based on endpoint type let timeout = 15000; // Default: 15 seconds - - if (endpoint.includes('/feedback') || endpoint.includes('/cleanup-feedback') || endpoint.includes('/feedback-status')) { - timeout = 8000; // Feedback endpoints: 8 seconds (fail fast, retry via polling) + let retries = 2; // Default: 2 retries (3 total attempts) + + if (endpoint.includes('/dashboard-metrics') || endpoint.includes('/performance')) { + timeout = 45000; // Dashboard metrics: 45 seconds (heavy aggregation queries) + retries = 1; // 1 retry (data load, not critical path) + } else if (endpoint.includes('/feedback') || endpoint.includes('/cleanup-feedback') || endpoint.includes('/feedback-status')) { + timeout = 10000; // Feedback endpoints: 10 seconds + retries = 1; // 1 retry (fail fast, SSE/polling picks up) } else if (endpoint.includes('/submit-test')) { - timeout = 30000; // Test submission: 30 seconds (may trigger AI feedback generation) + timeout = 30000; // Test submission: 30 seconds + retries = 3; // 3 retries (critical path) + } else if (endpoint.includes('/consent')) { + timeout = 15000; + retries = 3; // Consent is important, retry more } - const response = await fetchWithTimeout(`${API_BASE_URL}${endpoint}`, config, timeout); - - if (!response.ok) { - if (response.status === 401) { - // Try to refresh token once on 401 - const newToken = await auth.refreshAccessToken(); - if (newToken) { - // Retry request with new token - config.headers['Authorization'] = `Bearer ${newToken}`; - const retryResponse = await fetchWithTimeout(`${API_BASE_URL}${endpoint}`, config, 15000); - if (retryResponse.ok) { - return retryResponse.json(); + // Retry loop for network-level failures and 502/503/504 + let lastError; + for (let attempt = 0; attempt <= retries; attempt++) { + try { + const response = await fetchWithTimeout( + `${API_BASE_URL}${endpoint}`, + config, + timeout, + 0 // fetchWithTimeout handles its own retries; we handle higher-level retries here + ); + + // Server errors that indicate a restarting/overloaded backend — retry + if (response.status >= 502 && response.status <= 504 && attempt < retries) { + const delay = Math.min(1000 * Math.pow(2, attempt), 5000); + if (process.env.NODE_ENV === 'development') { + console.log(`[apiClient] HTTP ${response.status} — retry ${attempt + 1}/${retries} in ${delay}ms`); } + await new Promise(r => setTimeout(r, delay)); + continue; } - // If refresh failed or retry failed, logout - auth.logout(); - throw new Error('Authentication required'); - } - const error = await response.json().catch(() => ({})); - - // Handle FastAPI validation errors (array of objects) - let errorMessage = `HTTP ${response.status}`; - if (error.detail) { - if (Array.isArray(error.detail)) { - // FastAPI validation error format: [{loc: [...], msg: "...", type: "..."}] - errorMessage = error.detail - .map(err => `${err.loc?.join('.')}: ${err.msg}`) - .join('; '); - } else if (typeof error.detail === 'string') { - errorMessage = error.detail; - } else { - errorMessage = JSON.stringify(error.detail); + if (!response.ok) { + if (response.status === 401) { + // Try to refresh token once on 401 + const newToken = await auth.refreshAccessToken(); + if (newToken) { + config.headers['Authorization'] = `Bearer ${newToken}`; + const retryResponse = await fetchWithTimeout(`${API_BASE_URL}${endpoint}`, config, 15000, 1); + if (retryResponse.ok) { + return retryResponse.json(); + } + } + auth.logout(); + throw new Error('Authentication required'); + } + + const error = await response.json().catch(() => ({})); + + let errorMessage = `HTTP ${response.status}`; + if (error.detail) { + if (Array.isArray(error.detail)) { + errorMessage = error.detail + .map(err => `${err.loc?.join('.')}: ${err.msg}`) + .join('; '); + } else if (typeof error.detail === 'string') { + errorMessage = error.detail; + } else { + errorMessage = JSON.stringify(error.detail); + } + } + + throw new Error(errorMessage); } - } - throw new Error(errorMessage); + return response.json(); + + } catch (error) { + lastError = error; + + // Only retry on transient network errors, not on auth/validation errors + if (!isRetryableError(error) || attempt >= retries) { + throw error; + } + + const delay = Math.min(1000 * Math.pow(2, attempt), 5000); + if (process.env.NODE_ENV === 'development') { + console.log(`[apiClient] ${error.message} — retry ${attempt + 1}/${retries} in ${delay}ms — ${endpoint}`); + } + await new Promise(r => setTimeout(r, delay)); + } } - return response.json(); + throw lastError; }, get(endpoint) { diff --git a/Frontend/next.config.mjs b/Frontend/next.config.mjs index 124a5e5..bf93b75 100644 --- a/Frontend/next.config.mjs +++ b/Frontend/next.config.mjs @@ -18,6 +18,14 @@ const nextConfig = { port: '', pathname: '/storage/v1/object/public/**', }, + { + protocol: 'https', + hostname: 'www.brockport.edu', + }, + { + protocol: 'https', + hostname: 'images.unsplash.com', + }, ], // Enable modern image formats (WebP, AVIF) formats: ['image/avif', 'image/webp'], @@ -33,66 +41,17 @@ const nextConfig = { // ========== WEBPACK OPTIMIZATIONS ========== webpack: (config, { dev, isServer }) => { config.resolve.extensions = ['.js', '.jsx', '.ts', '.tsx', '.json']; - - // Production optimizations - if (!dev && !isServer) { - // Split chunks more aggressively for better caching - config.optimization = { - ...config.optimization, - splitChunks: { - chunks: 'all', - cacheGroups: { - default: false, - vendors: false, - // Vendor chunk for all node_modules - vendor: { - name: 'vendor', - chunks: 'all', - test: /node_modules/, - priority: 20 - }, - // Separate chunk for common components - common: { - name: 'common', - minChunks: 2, - chunks: 'all', - priority: 10, - reuseExistingChunk: true, - enforce: true - }, - // Separate large libraries - lib: { - test(module) { - return ( - module.size() > 50000 && - /node_modules/.test(module.identifier()) - ); - }, - name(module) { - const packageNameMatch = module.identifier().match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/); - const packageName = packageNameMatch ? packageNameMatch[1] : ''; - return `lib-${packageName.replace('@', '')}`; - }, - priority: 30, - minChunks: 1, - }, - }, - }, - }; - } - return config; }, - // ========== PRODUCTION OPTIMIZATIONS ========== - swcMinify: true, // Use SWC for faster minification - // ========== EXPERIMENTAL FEATURES ========== experimental: { optimizeCss: true, // Optimize CSS with Critters optimizePackageImports: [ 'lucide-react', - '@radix-ui/react-icons', + 'framer-motion', + '@tabler/icons-react', + 'recharts', '@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu', '@radix-ui/react-alert-dialog', @@ -103,8 +62,7 @@ const nextConfig = { '@radix-ui/react-tabs', '@radix-ui/react-tooltip', ], - // Optimize server components - serverComponentsExternalPackages: ['@prisma/client'], + serverExternalPackages: ['exceljs', 'pg'], }, // ========== COMPILER OPTIMIZATIONS ==========