From e29d2a0bb91b077ce2c4d0adc0f96d88d71dcf46 Mon Sep 17 00:00:00 2001 From: michael Date: Wed, 10 Sep 2025 16:24:05 -0500 Subject: [PATCH] made long actions cancellable (like persona generation, etc.), increased variety of persona generation with prompt changes and temperature variable, reduced length of key theme quotes, bug fixes --- .DS_Store | Bin 14340 -> 14340 bytes backend/.DS_Store | Bin 14340 -> 14340 bytes backend/app/__init__.py | 2 + .../app/__pycache__/__init__.cpython-313.pyc | Bin 6276 -> 6395 bytes .../__pycache__/focus_group.cpython-313.pyc | Bin 53770 -> 53888 bytes backend/app/models/focus_group.py | 7 +- .../__pycache__/ai_personas.cpython-313.pyc | Bin 44042 -> 60585 bytes .../focus_group_ai.cpython-313.pyc | Bin 56652 -> 58432 bytes .../__pycache__/focus_groups.cpython-313.pyc | Bin 78065 -> 79778 bytes .../__pycache__/personas.cpython-313.pyc | Bin 13703 -> 15230 bytes backend/app/routes/ai_personas.py | 880 ++++++++++++------ backend/app/routes/focus_group_ai.py | 130 ++- backend/app/routes/focus_groups.py | 135 ++- backend/app/routes/personas.py | 77 +- backend/app/routes/tasks.py | 129 +++ .../ai_persona_service.cpython-313.pyc | Bin 28803 -> 31182 bytes backend/app/services/ai_persona_service.py | 83 +- backend/app/services/task_manager.py | 228 +++++ backend/app/websocket_manager_async.py | 78 ++ backend/prompts/key-theme-extraction.md | 17 +- backend/prompts/persona-basic-generation.md | 3 + .../prompts/persona-detailed-generation.md | 1 + backend/prompts/persona-system.md | 2 + dist/index.html | 4 +- src/components/AIRecruiter.tsx | 149 ++- src/components/FocusGroupModerator.tsx | 70 +- .../ai-recruiter/AIRecruiterForm.tsx | 37 + .../persona/PersonaModificationModal.tsx | 59 +- src/components/ui/GenerationProgressBar.tsx | 65 +- src/hooks/useCancellableGeneration.ts | 278 ++++++ src/lib/api.ts | 44 +- src/pages/FocusGroupSession.tsx | 29 +- src/pages/SyntheticUsers.tsx | 63 +- src/services/websocketServiceNew.ts | 20 + src/types/cancellable.ts | 93 ++ src/utils/personaGenerator.ts | 148 +-- 36 files changed, 2193 insertions(+), 638 deletions(-) create mode 100644 backend/app/routes/tasks.py create mode 100644 backend/app/services/task_manager.py create mode 100644 src/hooks/useCancellableGeneration.ts create mode 100644 src/types/cancellable.ts diff --git a/.DS_Store b/.DS_Store index ec5e5dcc84d5e2e9cd0df31e4f78b2f406901a1c..06b6cb84aef52a2bbf86a99cdfe319f25e7f8cbb 100644 GIT binary patch delta 37 lcmZoEXerq6Ux3}%P)EVo$Y}C9MTgC9f{Ac}&Fd7y!~pEB3r_$5 delta 37 lcmZoEXerq6Ux3}jNJqig)N=AVMTgC9f{Ac}&Fd7y!~pIl3ugcT diff --git a/backend/.DS_Store b/backend/.DS_Store index b2dc3ede0984a07ddbf133dab786727bd3682bbc..a3aa0abbe181f8214b0d0c1364b73f480bba5c2b 100644 GIT binary patch delta 57 zcmV-90LK4>aD;G>PXRr#P`eKS9Fq_P6|*4_umlMN0CWIl0BrzpldcsPv)mLK3$qI# P4-K;g9qj_M2Qd8y@u?8z delta 65 zcmV-H0KWf(aD;G>PXRo!P`eKS8&^jg2B3Y<%NRv;PRoUo&(v> z5Ud|;pwAd=Xvq|8B*kC~RgotQ3>yY-;XIL0k)Uu?Z6IDmkSP;HG*2{?DO5B^2c{-e zRE0s2AzBE=XJ<%cVPH^TkY{jb@+(MVuw|BKNMqDA_A6rAJezMX6Ne__Esm1J;_S&G zf~8zs`iTXZ`e5#H!8*p!$!%iiLqh1H%I;xeMC%7x^6yC|}@ry};pG6b2O6 z)ZM&HXbGdRKw?3GUQvE&Nouhk$c*A5-^p$w#*AWsxJzk{98nZ zRSd{fm@F--&Q$?q=>l=_jLklx8yT7JvS?2JD^@0agN6M%i_}FHsTmdXu9j3^?R@ zgEjEV^95_-mFExE0?XIqvOpkM8?OO^!8&mHKvc(rc)_~CdispP`j$+=22u>BPz8BH z!HmHS-okmpp~6AIAi>Ece3F79p-iD7Ia*Lf451>E=kZB%N2M{?GRrfhF=`raKEii^ zY4QZY!pXcsHH;yXyM$~Rn?0TsdA*B M3^)@Q8AZU-0G`rRnE(I) diff --git a/backend/app/models/__pycache__/focus_group.cpython-313.pyc b/backend/app/models/__pycache__/focus_group.cpython-313.pyc index f5181f7873378fd8e0c41b9915c8a9f5e693469a..f9d8e2303e20afe8a35fb40f03c30a2545d54d5c 100644 GIT binary patch delta 3660 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 delta 3505 zcmZvedr(tn7Qnyn-rSo9uRszYD6fE}4C@Poq6o-KP!N?ETfmA4Q4kH;o8T(Al^vPs zO0}p*>EheEt%Dt{^;Wb#x*}ED%A(lT+VtVGtwVj)()2x9e51a;8EB}UU+OO z+JygS3XjGZA!dUHZk8HhPN_dMWg8(oTc>h}HW8}pOlD%$j;B+ImDI7Mm(opqso+l> zYH!$bU8TvJFCjLyFAtWjUkdMA#gNg&p|PnAq-X{q4sB)FuO3>fzrYqgOB-7!u(eH7 zW`_5|4FOu8hE=BYf)B81;pbRmvNg~e=I+4Ce<&qLUcB+EE?8_8%#!X5kW4wI9lp33yRZALc?Th7!!_uhU=ut0jT)42_vCyMkXm^*u(&0-Dev*y( zN%I&yBC72Y{4zWpqy`gYMh;JCVhUFuo5NeAm0Z_^UJ4HXGT1T{Y&jn{;j`e3zlwKM z!?dB7U~yy=y#%jCj%7R%IfF4UDwlC)R4Tm#uSQKUUGfjs@~L`IJW2urv>9xedb&$m{6r^>miJDK6zCfGIA<)~4i9qfCE2lU zorpb%YzB{x_1f;XS2a595_>_2v*b(AC)MxA$9TTdyNCnOQIt<52r3*4!jv@beFB$Hg~O%Fk&xjSDZe}A z14_G~zId(J!VbgLa%g0Ky^5x3l3N>sMli#Q#qKq!ys>+byXE0F(g1g?m*e>;gu_j5cRP;5*{|50bOs|O_cLhZ(xm)y> z{FDKze${(~>e^a+Z8s-fggrHr+0fjsiJ_YzsCJ6#BC~|-mRR_6?P&U6d0*{YLaqnB zm0-OndACIAJH%zgRfLk&6_k`?xrWkp#0}WEC_nlK6n{iK)8`lQ*=D%EXj;q{O!R1# zlB?=#UD8clYkX^#Z*4<4O^`oq_y-#I~?%jV71 zr8Iq>vy-P=A*pRR7~I{|A)j!M5$N}DdwDqR1$Fae)AtzRA>t8&utSkJnBV-;vqNzo zK5dR=vG%nmf~lpMzACS4p{fx>=!KIZEnx7dT}?GLc9$!We;f+qHA2tIsnJ`}Xc?^3_c!>M_VJ=tKIh69Jj=yMg@iKnTgM&V#XZrBXSS|8j6T5toSS% z0{?E$6u-vZm%)r3M(A#_$oAKYcsd08UyrujMWJnm`>y2_QrKIL|(ZmP^CYL+L;LDZFj)7 zu8GVpsjU!VH_sMxG3s?C>bW)zbZtIMO|pGUDaY=|_qP>htwPVSSWq$|1u+hR-*VoN zc;!71;_cNnby6dfySZi|-r3};lO!0wJ>NSU%J|~#`P3CqKsUgT+iPfrT)ZQT4^ysA zWj~CzLIobOqdR)#IKVp1x}#y&i>{;?^uci}X|#Kg2p7WAHtwl6YVgI5ku zr$=G%aD5yG=qA!E_Tgd$F!N`vZ$ib9)vRMTjvQmH*!jUmJ=P+nVKha00rHQoj8N|8 z8q_F9RE!b^lY7h^i%acXe&GyIPt9e>;}+99Hha zVPhELJe0`qz-^t9}Myi=evV0Uk}n2Xuu!Oh+>F_u;BJwEW?GX~mDmD9=c zcc(fyRWAt1!buyGnm5l7>z{YvI7Kq#bwhiESCXeRA^fPb7D(mnNk_|l{cCvr3^eP-$4bkJq3Y}k??l2+X9d%8)I=bLA@Db& zR07(8B5@j4^O17vGz_RHtpw*k>sL0Q!AiuF-xcrR@*v_c;y7XuaRKoq;uhi#;vV7w zLW`LNA`FN~L@Xi^F$$4^$VTKNifB0FG!*eTq$MaVMJxyB;IJs{s+5QtW$cu`AadEi z>j-oYPS!^Yp?7&AhTP$pbSIa}4+otb4T8p@IKA@qWk7cmVho%diehD79*U*&fnJD# RvRe0f)uq1S8urII(32*<_+TDyOP@ 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 diff --git a/backend/app/routes/__pycache__/focus_group_ai.cpython-313.pyc b/backend/app/routes/__pycache__/focus_group_ai.cpython-313.pyc index e416ec314547359a511defd79d13221f98467c36..d55458d6338d07014ef465ecde18919c6d2c6b9a 100644 GIT binary patch delta 8491 zcma(#3wWE=b^ouoB|l_KmSp*%mtV3R*^ceRacn1!aU9!;^AJi%9WaU{+ak8)UdaKQ zQH961QlOCRGH3}b(2duC4Jy7iu#J%bfsl~NBnENcm!sgli);=4tv2jj+d0z#oVB_5W%9g$=Qq@;Ysu?1$-`-b4YS>ua@93)~ zwPtRY$jZ6$cP(ocq52$0oV%RchG@Q0v#h|SspDPRRia&T*i-1zt>RsJBiAf!On;O5 zG=(7pMNtNdhBSrIReYU{k_;5f(-ftyvg;^J87LYBiVwB58AMnz5HzJlSTmSm%Rteb zrYLt+T&GlJ28tDFimD7UsxwfmOjFo1P}F3gXh~BzGFV=lfnrsfVp#?XX9kMaG(}wo ziuw!`tJ4$>8SGe|fub!<(U?I-QwECmG(~d;87neSbfhU(W{}a6fnrUHBDyMrjMfYk zYh9~%DR*`5%5D-#;pn*9c6ByMT6g~gk`|u^0Cm!(0F(x$q$omL?{+?2`BT2-|G)XhEfQ!ApLkvvAPWzv?J(1&_yrT zShWP{vs1SCJP{AsPHP-5O7DY(ide$Yu2QLBcrW~2S}Tv4SL*nviw{c5xWOF2u}`Xq z1|@@eDV$5`mbgT2zMgLu3pk$ZneS+295OT9VMb|ChQrGfQHtDUXcC1*&_Cf{ zs&Vzge5qz;uAHmmf~vu)>lM;)8z2iNMI1Nrkvn^d%F2|4)&h?6!TKg0cv4ln&7NYi z_7%i^jQi{1sT%}HFim{p7)WdNq4xGIht`)#%X}HotAX;TC zTBapRs#3hbujWQ^rWKjX;* z!anX?I-}Fmr&cSYl}oW{m9NVM6Xazuw=*|mR!CCkN5N%-;^hl_hxuVnP~XDUax8zY z+KKsi1M1@gPYsLEVl(_3;ZsC|PNpq@ZS|?q;y0KGryRV$^ni$~cJTD!)kVvyUG}Ro zx(0(_0^J6er&s`k?rVJHSn7?GkD6;;%dXa7s8kpDZ5nM}|D% zsi1f0cK_~h$h!yTEZT<@dt7E5P}~9NXo*YN#gY< za~U^pXnbPKkGkkxlU^_MrQyl3v8j|oexG?-wWw0qE+IcaP7feJC4i0K2QTq^fDQeo z-Bt{{*qTgO6EtLWJ+!4`U9`SGWn(xc2>fUE3LP9|4u=T*K_@e_PG+9g_N3jsxNX{z zna}N>@Zu)(?kV$R*iQnNAa|#0+(G{xG}=++-GLU|1i)@2kn9}Ui2&;Z`3{0&1cL|^ z2%rjb$zspMg!8(X3b7{N#~s=J@jzrK5*k90^j|v6Dlby|5Zp$;?l49%$C6q8;3Qlp zz+p)l@bm|LBpF$cLJeg?!uME@9QQcL`VQu?RjJpcUlp!pieOxl!w;|=4GZz z{&k9Ceuz2pDBZEf(Gmr+Fm_z?)gN*%a(+Ao)Zn%7CFr}qgTin)qxF^1Td;F&1c=yPxX7r3^G(HyogN)1B!k;&< zK5cG~XP5pmPnXbD&FY$FbWI6uaYAeT)K-(IXiHSM;y3P&yS#Cm?}A)XsJkRr=nJlJ ziu{6k26S9e`JucY=e?kco;Eby-#af`m1CGQIcH7lJ~pjO*cuXMN206>Xw5(-F&6BX zUC!n5OJ>#98MSp*T{WYwdZFfZ*I`|3@YPN6&YR=6jKyyrk5>oJs6&@!oNd{xwew?Z z=iAQH)}6B@JNIY(o8CO9HO*=(XS9{G+S(ayZG735Guo}uxngU=(vqOpW^{fV&y`ok)#f>~{e{YN=7s|s_ID=qh9?_;GgfW(;)+tK*s4L`BUDE1$U~;i!MLbjGnJQR#R-@J!%U#bN8|%JxKM!v&SB z!T4pAlNA+TkaCvgU+Oq;q0upGY?(2(Bn&2C_i2?gVP6aX-_^vs-KRUZ$Jg$N@7Nu$ z^3FLM;>*{ac6MFJk`@^+$+L=eSGcS~-8_QJIu6Lek!8+AWmCdb37gCXmpG|GGq2IBF&-pc7-tUp>hcy~Rt*v$jOyZ4n5t*g)p5s$lbR2_$C}YU&TkO`>#whDO(D+#^71Vr zE?YCJvdpL~AX=)PQ)p%t#uQTnd~ccLDGQ9=Lfk*s!{|sI z-)oT^sV{=@(JVwCRf=$&qwFo`j_NfyuHk!YWk(%i96OiyJH+pp`QAF&JC;HmuPo~A z5WTDBdzZ`J&F@sfHUw^{aHX>=7#j_I<2?wFPDZIK$q(j+XE&Wi@DekvPPpEj9oXC~{-3c{HJ5j>-n`9?Sr8u^tv=cTd?x_~z z9*1nBNPeP$?{CUJu{;~c9q5dcJnlRxVqfrwP!7uLbq?U-14nzN(H(Xfr1DvC@kC`1TE1AOiR{Jl4|$~4Y!O%HOb4c z;jP%&jZ~~dEx*nRh9c1OC!rPM9SiO*-`i5BVXDgDx)hlVrxbmA%X$Sm-mV}Tn!DB1 z`K>xG+>KsEe$02!$51Pw$GwJV1iy|2xBfo2oOM$>BA2)gr+CjIjL8am_pSA^zr(rv zuYZ0kFWZd6<*|SA?Bw}3souLxHGmj~-a-aZl)Z4*tr*C=h?A!)xfzQAB<{Jod zH|{G(um%AW&LY92ZA9EA0C1K15XTI$olg69@+avT-$#58{fWPCIrD>g$pz+`y95C{)>CL^I>XgmbZ75KtJUI*%= zjQGQoV-fN$vU(4}&k>#L35OxchXZ5&U?o1p`~xl>M(_r%t3BRGU~eEYHRKHiBk+hp z9-;?FTAlacj_tTZ;q!X~VZ5a?cb>on7J@&+(On4kQSs;sK1y3h?fkveH)@rB3g~ZQ z4~`yE!jp?5TwR2}2$J$Z#6OM)hvflF)*d<*?z^fQNRZ=B1Q-J0$ZL>VhlVhNJ&_O@ znh1ph?8n2mCh@@T=wQBctbWh z0WV~5-=Bv`#2T=qmChg2i-D z!F16}kP=jVz$%r%)nr|48Ihwgc7w;sbzSj|@uEsh2I=x-jgu2@uv=_3*J zxrDnG>nUak3KNRFb;M8e?=H&0pSM`Ah%1fijDt=PDF{qau8(DBp2}r-iM|XK1ZKCSVSA1 zJ|a@_vCB{YN~}+JqojmYi(}{=V;9}}td{=I-zWsJ>i7TH zvu#mUh1dx$#O-Ghu$*H>doiV1jMy1Q!|d`U8OW5upMnG>CUc-lPlSSD{}6lhW!A}I z0aF1!LN*x3J$w9cBxu!hT4@2$eimE%+%1w~jCxWL2!;bAqmh(z0XJQNp)aSO{B31Q z`>dB*_(HnwrShIXt?ELb*P?<h+>LXHBOyn|p9-TBk+K_N~2)N5oE$U-=DY44LN>W=l7ES@R#wbesU|1s7W zWe*Dxq-TwkJu$Fwd}}%_EtE`fT4^E0*x~O6j>&AVXKXC>fG@b>+Nw!|uNO%z;El%E zL$9BeSQsxGx(F>v=0!pg&)86^TZW-h-1Ei;-=bB@g-U@GEUB38x1l}XYQVx>aj|$X zc`Q;$jX8sb%K2uov;z3v85?->3z_^gSQ;Q-(XJ!i6&1+827v>>v}XO1$02rUY&DZ& z`pS_C6)Q4TxYJEPIWnqVfy1pxBz8KTV2=A}Un`meFDZ+;@55c!^g9+P7M?S>%Iwt8 z^G6-4nKsN?RvVXgXjZH7KD?wPqZno*8oB13QmF%s{Bg|l&O;);JNCtUYZZKPtnSn& zd{sT{gd5)zNRQ4<%KUf0b;g&J+!hK1?b*VWhjc2;&V*V4V_xq~j@YGWJ6J zzr?aNI4_SKJX6411~71&0i^Dr!XoYvmguRoR`tC&52iql^e@iU@i)-=bNgfm5xIrF zc5Yos5k7~(8QL3wcS9$8PF#5ABn9;M=c;%O)z3EbTDobrUdC#Vj@~=FjMvlGX3Kd4 zJvUn`D?)Zgsz{U=(cj4eBNznX&dc0|#=giAc&g!q`lV_pgdC?yf9KYfdZY{N)zss;z#H-=OOjL@Xi=USuV*Un6 M?C|;hVhIZPU;T@doB#j- delta 6706 zcmai23sjrOmHtP+Ug!Y={U9NM1d@2jyzCfk^Ds8HgRw9)w!klNr~&8)hmV;%^Ud6Q=iWOby*A4K{w$w*O|RE*^wYHVOy7~W4yT&Yfx|^xhLz0*PdZNb zWZ(>s5gR=M7Vra8xxi*r3TY-4?G zv)z-2^H^KgobPd92W#t_ogNo!&ssdWvw7i*?ePi#=}a&f_{|4$f=nTu>zg zRXGlqbe3$~N=Eh=%hJ6W707F>mUXJ=9KoAejl5YFu1cDi_>apH3{w(@ISHdY!N~Sn zzP5)o38R8B(#M2SZ|>I!wj_jwi7o6&w#ZAuSd?JoCo#j3gmGJf;qC|-Acj_u-3Yt3J z+Rpk)xwp>3?UdscH;pat)XtsD=GS-DGmhH3f^k;%D%>mgKNJ~NJb@y6RNpZc=0n~( z0Gt$J+ORkyG#ZbuCD|Y#NBt`SdABxT+3wgG<1htaO6s?cB*C-bQV+rxNo9wl~Gx-Jw%ZI}L z{!sZK4h{^4yZVE^9uea@@{)T$-KKQG_Eq!Emu%4P2zC#Jx_WVNXb{iQ64vj4l~w)0 z-mZaAZw&3gYsh~tu*dhFT3|K6-nb56IlwAnEpZ5U15FndC9sR`SXAER;#6tF!gWc4hI* z#GVRr`g9&ys1wNX4h>mXuiu{LHF(p@WpTbYqf+MLD!72EBlqj88@Z<2Ic|vF2ZIl` ztLN;mjPq+UIIf3|w`5VRROE2AVBXRPvD%*EHO~AOr2k-gT4n_ozh1mqUK3eVD|k9! zoPE6Jc2zs_W|#Bv^}QC*PtC1Gtp^z{RdcP^=-a})2 zjIvL=hHTiB&*yYF$@pVN{XFBz^kX)GuH=eOrYqe(f2G`;S4y{e?ekqoS1BM5*T1c0 z;RkE&FC<^>Gq0Evc@XY}bcDquh6BQGfCT^$m2O_jBgi``Og&`t%Hzt7&~GDOu3VJ$ z05lH(9HbCahJr)5TO>ytoLMJ%3@H{<}p&zimD z)u4WON?DR3Oj;LASnJ=i)<^AS(VU{F#W}5#=cInh$+J=qHC|8U^yUejZCq!Y&^gC- z&L?;NyYaPJ!3DEuIFq?+9Owy_{8xF&>>aiJt?D>~XA%`bd%&xkEr zRQl}p@uJ$Oqv#p;i2K>57w@{{sE#_yrnSnltiN;0Ow+W2%PqeyaDsI>N{zNGh&mQB zi?4BVA#F;{r3+IjazU8lq?pSE5B*{cg_>-7rM zSSWveVK!~Qkpkj3H0jXJ$!;v;-l#&2ZuuKa8nm=?4xz>p`8ioNv}$&-qx(r-tDrbaB|9S3trod!jklwXi+C zV(HvS-rHEGZ=+Oq%8c&B<;}nImU<~9-V>k*C>K+OhPu1y?JubXMl-co`o|3Pp&`z# zIQS=Y!lRKBUczUAmS3anXV9_&3mIf_BohYb$z{Jhkh;P$L=a_uR((kT<95-&F8f>}ja~ za5%KbAMT?b`?%k%6=BI#05eG&7%I+&Cad#)XoUb0!5CBR@!^0!(2E6fYQQ{fh0z`W z9Y8lgCO{ehI}C83J5P!bc4f@JEn*q|P?v8g91H{pg7ozU?@;^<<;0X&3=Q>%@vmU1 zGXOsaajGvAqWQDK-!BFV;ko((3_S;M3dRk-?y!HiKfJH2I~WLyfiONm{DGSC@4$?9 zn4#_wyZs@5Fd(_}6&PSga1XS;1u#s;14~j^QsvP;Ne|k28Y<+rphIzq3dSQ_gFn&G zOY!r4#o2{a5L5Z-3mj62MFhL-8%fQ6&%8n)r!#N9ku!ju1tu}mec>SP8VrW~VNmdv z;XG3cm_y%zRVM)61ejk6vSuPY3PS)#=zSY9k+kO>k=lEh4evehE@~sE4;)ZJis5|H zaIgmzlBW+kXk=YHxVw%;mM|Ly(mBr>!vf6+TzI&~H1tIAP=JMri`;W)xs;VGrqnPC z>LESHo*rg$m$(~F=>iB-#J%0(Aeipf%n6zI$mfTgE5WdsYA7UP>gSjq+$C|r?O=LH zLxEKq22yJOk;*W@xyY6WFKJMWl>XpdHGChS53!R(|9GY0B<;kMEN(<9JQ3OOcs^pK zQPSZGEOC{>{xb61K?@oAp+G(J%O7sq2%f>LQVKUGULt2CZ-Hm2;hGKX5&Fi zyR)8wP>%h`q4X?t2agF5Ih$mjvv&dgZ@;BTv z4W#{JdbkzDt^r?we&58Ht#y+ou<<|QurK0!s!zIX=4wNS_vfu9Fa0djnTN$N_S4Ah zp#cF3#!~3w{rkfH?!@I|dV1rmns+~4u=Zc5iNAvV5u%VpIT^?Sj*|DEzAC%HMP7O4 zDxY2ioMKQw&g#R<}Q*EX4AxQbM9x617{Zxvu9rj2v&e?0JBM*F6hB^zkN+xxYBC_c}-j?UH4NTxSFfW!C z4u^!rjZ_i709#mmF@xAk;g-=q^E{j_5^O(qe88R)Kbi0{#-cA2 zgv+C=&reGw-iH<0RKb>r`SrgjRWZtF#UB&>=P-3kHP$TOpT zhAL>@L18~%US3WuvS0Mnuq!R?UtDPxU~U7r0KiPmASW)C)Uv!`21D}1ALg@Vc`wYL zt-f&9%uFbWDR1T~?9`xLk&-tbmZ3F~GrwP{Mro1HFTan9DquM|PJ9`NDaF0?5z`Zs z?+p3_Zk_Z{`8D~+J9iYErE^2@?vj>{Z$JgOKMuzuc;f$fWj*wr5%IkYl)sAZjaO5M zzdWTum>~_3Ba;pT`=&ksLV&As^5$d(Y9L=s-iOwZ``%wg{`-9=${@-sRme!{t}N8D zLIc?wDia^tZk_KcPqugp4_Z^ruZ4 z6FzJuWmk)bzY7aI0&o-nzCN(WJH7<1D8L^9J_CT;N2>g+!a`MIHG*XUhRDKj8q$-O z`3(#{NmCu9;D(sGj_NqnFV^D@Dnk$lp>5>dtFFvXVrplIjsFp{dfIn diff --git a/backend/app/routes/__pycache__/focus_groups.cpython-313.pyc b/backend/app/routes/__pycache__/focus_groups.cpython-313.pyc index 0aad1eb9fa09615fc558410a8fc8a3b6f74ca0d7..f186e3892f5305bb0945fbe668e7ff0c66127911 100644 GIT binary patch delta 12109 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

oIQ~5%G#BcrruU#+ zdgKQcZt(q$&@HLEdWSq)zF&kof1*ge7YN=Cq`ti@6V}<~e{L{rN9LOlRIxpOzR@&{ z&6vf<>OZl=m)qT+bU*-{|KwX(e4qdC6XXTRBiZcGr_JS6FlD=y@bI-NXdlv}7~>NL z%lWK~%xCjI3z9nmDW8{+6g~&P5~{ieAO_8>`P__j%=B~@^5DCGvb(1S($QV#R)7p= z&(#+`07Ju><1R{aB)gn&%Q?^L{?ee4IWzZP?$>biI<~7lk5&Hl)y4af@c{&n0f3tf zckYBt4Bg7MuLHwIeTqn8R88>~C>^DAPgBv1vkxpdDp^JDQBOX&?cA;_qfV1W?ZdX|}kwdnGNTi$35c zDPi5vUL3^*M=^lq@0i?*DLAm3J$XK(5Q7ZXiDRpamvA`;uY1@)BV_W`C<)k~<9baTxBKd;eiWBMK#|l0<{P{h&lfC{$ ztAV@k37qd9mh?O{*yHli7NUyFor!Y8d|4OiV5QiIkePW14EGi`K>)Y5>9gYA{0yf zyH-Ms|9Q=v?@b~Kl8vB+yM;TN=V9EJU^9La$N2Wc135v~7M)4o<@S&}@HZWDy3hha zXw#81ZbDQRItnDCPwydDY9{W^5Boba$W@c!yDe(n4&Q81zWqu$8~hffz=6=$$7@4} z2>;VsQlijm{F0WmXnu-Ty3SvrBgLt>I_Wf^SVD1e!X9>wzf(u%%in6v@r(f<8(dfW z;x7ibmuKATk!y5wI)tyNa5STi`#{AV7%?6<*m8$m z$cpfg4QYVd0>mCSIQSCwnKFa<~kWRi-5pk?8&Fe~XEf zlEeO8@D{TnN0vY3s`0fSRd+&g806rHD#_Q*QJm(7)iZ!VQROT-vUJ4&S9K^FBVL#^koEm*?i)EC6i=!OfZOkqCt)#0cVJd zhxX}7 zTRhJyNaIyNZ#cj+xguDp^4kkYvici9_^IDpK<*=N9sx}{Rfd=p>fcsE7LjlLFO-nW z$P0dRDcLQ%@mriI6{ppT)zV>|B+|Q$O8&vIv`sW?d{eT^iKcgN-~Rl z?SG(>Tr-l5#>GYMhM#rR^3rao;Q#<5?qB`X}>s&-kMUahv=ld<#x(UG)G6ICjF;4Z`HxJ5*jVp@YmEgw^{Km?E zyJhD;=%Ne<)bJxJ_?r6&vU(K3GYF0$IF8^nf>8wTA@~5nX9&JSAfWgoYJ^4v$q0DK zaU-_y5=!|U3GXd(hUNzdJXFi4h`re28!qlKJUvp(A$&DaToV+-iJ~L+3-nE#wHSb; zn+@SS)aRN@6Lb)aFf4dG{nu2HLQ~@hLiT$?@_T~eJwgATkaT2U6*;LaxJp6NE-D2@ zK7#cGVG9}4VE3hN^$}J}aukXMHU2|&q&joDc9m8LYBj4$l)*&9ssd%OKn?Bx2SX#4 Axc~qF delta 10375 zcmahv33!y%wf~)cGRe$jne59>RtHpJs@Hs9R{E7XCMrX7ayT zG>iYurrGdUzc6P(F3q(H&`w&Iw;-S9L)(xm&;l_TV4;`-ut+olw5}k`K6+{

nH=L7apJrwv5~m1EEcEc3Tp}bA;~GK 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 HHMIX1_f^4n diff --git a/backend/app/routes/__pycache__/personas.cpython-313.pyc b/backend/app/routes/__pycache__/personas.cpython-313.pyc index 4e30391516fe06cbf53dff6845f351d043713459..89f68d86eb8c1ae4c420e15c996326df63ef40b1 100644 GIT binary patch delta 4278 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 diff --git a/backend/app/routes/ai_personas.py b/backend/app/routes/ai_personas.py index d5383c09..79d5aba1 100644 --- a/backend/app/routes/ai_personas.py +++ b/backend/app/routes/ai_personas.py @@ -18,6 +18,7 @@ from app.services.ai_persona_service import ( enhance_audience_brief, PersonaGenerationError ) +from app.services.task_manager import register_cancellable_task, CancellableTask from app.services.customer_data_service import customer_data_service, CustomerDataServiceError from app.models.persona import Persona @@ -61,36 +62,42 @@ async def generate_basic_profiles(): if count < 1 or count > 10: # Limit the number for performance reasons return jsonify({"error": "Invalid count", "message": "Count must be between 1 and 10"}), 400 - temperature = data.get('temperature', 0.8) - if not (0 <= temperature <= 1): - temperature = 0.8 + temperature = data.get('temperature', 1.0) + if not (0 <= temperature <= 1.5): + temperature = 1.0 customer_data_session_id = data.get('customer_data_session_id') # Optional parameter llm_model = data.get('llm_model', 'gemini-2.5-pro') # Optional parameter with default try: - # Log the request with model information - print(f"🔄 Backend: Received generate-basic-profiles request with model: {llm_model}") - current_app.logger.info(f"Generating {count} basic profiles using model: {llm_model}") - - # Generate basic profiles - basic_profiles = await generate_basic_personas( - audience_brief=audience_brief, - research_objective=research_objective, - count=count, - temperature=temperature, - customer_data_session_id=customer_data_session_id, - llm_model=llm_model - ) - - # Log successful generation - print(f"✅ Backend: Successfully generated {len(basic_profiles)} basic profiles using model: {llm_model}") - - return jsonify({ - "message": f"Successfully generated {len(basic_profiles)} basic persona profiles using {llm_model}", - "profiles": basic_profiles - }), 200 + # Register current task for cancellation + async with CancellableTask("persona_generation", user_id, {"count": count, "type": "basic_profiles"}) as task_id: + # Log the request with model information + print(f"🔄 Backend: Received generate-basic-profiles request with model: {llm_model}") + current_app.logger.info(f"Generating {count} basic profiles using model: {llm_model}") + + # Generate basic profiles + basic_profiles = await generate_basic_personas( + audience_brief=audience_brief, + research_objective=research_objective, + count=count, + temperature=temperature, + customer_data_session_id=customer_data_session_id, + llm_model=llm_model + ) + + # Log successful generation + print(f"✅ Backend: Successfully generated {len(basic_profiles)} basic profiles using model: {llm_model}") + + return jsonify({ + "message": f"Successfully generated {len(basic_profiles)} basic persona profiles using {llm_model}", + "profiles": basic_profiles, + "task_id": task_id + }), 200 + except asyncio.CancelledError: + current_app.logger.info(f"Basic profiles generation cancelled for user {user_id}") + return jsonify({"error": "Generation cancelled", "message": "Basic profiles generation was cancelled by user"}), 499 except PersonaGenerationError as e: current_app.logger.error(f"Basic profiles generation error: {str(e)}") return jsonify({"error": "Failed to generate basic profiles", "message": str(e)}), 500 @@ -129,9 +136,9 @@ async def complete_persona(): if not basic_profile: return jsonify({"error": "Missing basic profile", "message": "Basic profile is required"}), 400 - temperature = data.get('temperature', 0.7) - if not (0 <= temperature <= 1): - temperature = 0.7 + temperature = data.get('temperature', 1.0) + if not (0 <= temperature <= 1.5): + temperature = 1.0 try: # Complete the persona @@ -185,9 +192,9 @@ async def complete_and_save_persona(): if not basic_profile: return jsonify({"error": "Missing basic profile", "message": "Basic profile is required"}), 400 - temperature = data.get('temperature', 0.7) - if not (0 <= temperature <= 1): - temperature = 0.7 + temperature = data.get('temperature', 1.0) + if not (0 <= temperature <= 1.5): + temperature = 1.0 customer_data_session_id = data.get('customer_data_session_id') # Optional parameter llm_model = data.get('llm_model', 'gemini-2.5-pro') # Optional parameter with default @@ -313,9 +320,9 @@ async def generate_ai_persona(): ) # Set temperature if provided - temperature = data.get('temperature', 0.7) - if not (0 <= temperature <= 1): - temperature = 0.7 + temperature = data.get('temperature', 1.0) + if not (0 <= temperature <= 1.5): + temperature = 1.0 # Generate the persona persona_data = await generate_persona( @@ -361,9 +368,9 @@ async def generate_and_save_persona(): ) # Set temperature if provided - temperature = data.get('temperature', 0.7) - if not (0 <= temperature <= 1): - temperature = 0.7 + temperature = data.get('temperature', 1.0) + if not (0 <= temperature <= 1.5): + temperature = 1.0 # Generate the persona persona_data = await generate_persona( @@ -435,7 +442,7 @@ async def batch_generate_personas(): } Returns: - A JSON object containing an array of generated personas + A JSON object containing an array of generated personas and task_id for cancellation """ user_id = get_jwt_identity() data = await request.get_json() or {} @@ -445,55 +452,68 @@ async def batch_generate_personas(): return jsonify({"error": "Invalid count", "message": "Count must be between 1 and 10"}), 400 customizations = data.get('customizations', []) - temperature = data.get('temperature', 0.7) + temperature = data.get('temperature', 1.0) try: - # Prepare customization prompts for each persona - generation_tasks = [] - for i in range(count): - # Use a customization if available for this index - custom_prompt = None - if i < len(customizations): - custom_data = customizations[i] - custom_prompt = customize_persona_prompt( - age_range=custom_data.get('age_range'), - gender=custom_data.get('gender'), - occupation_type=custom_data.get('occupation_type'), - education_level=custom_data.get('education_level'), - location_type=custom_data.get('location_type'), - personality_traits=custom_data.get('personality_traits'), - interests=custom_data.get('interests'), - audience_brief=custom_data.get('audience_brief') - ) + # Register current task for cancellation + async with CancellableTask("persona_generation", user_id, {"count": count, "type": "batch"}) as task_id: + # Prepare customization prompts for each persona + generation_tasks = [] + for i in range(count): + # Check for cancellation before each persona setup + if asyncio.current_task().cancelled(): + raise asyncio.CancelledError("Task was cancelled") + + # Use a customization if available for this index + custom_prompt = None + if i < len(customizations): + custom_data = customizations[i] + custom_prompt = customize_persona_prompt( + age_range=custom_data.get('age_range'), + gender=custom_data.get('gender'), + occupation_type=custom_data.get('occupation_type'), + education_level=custom_data.get('education_level'), + location_type=custom_data.get('location_type'), + personality_traits=custom_data.get('personality_traits'), + interests=custom_data.get('interests'), + audience_brief=custom_data.get('audience_brief') + ) + + # Add to the list of tasks to be executed + generation_tasks.append({ + 'prompt_customization': custom_prompt, + 'temperature': temperature + }) + + # Generate personas using asyncio.gather for concurrent async execution + try: + generation_coroutines = [ + generate_persona( + task['prompt_customization'], + None, # No basic_persona for this endpoint + task['temperature'] + ) for task in generation_tasks + ] + + # Execute all persona generations concurrently + personas = await asyncio.gather(*generation_coroutines) + + except asyncio.CancelledError: + current_app.logger.info(f"Batch persona generation cancelled for user {user_id}") + return jsonify({"error": "Generation cancelled", "message": "Persona generation was cancelled by user"}), 499 + except Exception as exc: + current_app.logger.error(f"Persona generation task failed with error: {exc}") + raise PersonaGenerationError(f"Failed to generate one of the personas: {str(exc)}") - # Add to the list of tasks to be executed - generation_tasks.append({ - 'prompt_customization': custom_prompt, - 'temperature': temperature - }) - - # Generate personas using asyncio.gather for concurrent async execution - try: - generation_coroutines = [ - generate_persona( - task['prompt_customization'], - None, # No basic_persona for this endpoint - task['temperature'] - ) for task in generation_tasks - ] - - # Execute all persona generations concurrently - personas = await asyncio.gather(*generation_coroutines) - - except Exception as exc: - current_app.logger.error(f"Persona generation task failed with error: {exc}") - raise PersonaGenerationError(f"Failed to generate one of the personas: {str(exc)}") - - return jsonify({ - "message": f"Successfully generated {len(personas)} personas", - "personas": personas - }), 200 + return jsonify({ + "message": f"Successfully generated {len(personas)} personas", + "personas": personas, + "task_id": task_id + }), 200 + except asyncio.CancelledError: + current_app.logger.info(f"Batch persona generation cancelled for user {user_id}") + return jsonify({"error": "Generation cancelled", "message": "Persona generation was cancelled by user"}), 499 except PersonaGenerationError as e: current_app.logger.error(f"AI Persona batch generation error: {str(e)}") return jsonify({"error": "Failed to generate personas", "message": str(e)}), 500 @@ -511,7 +531,7 @@ async def batch_generate_and_save_personas(): Request body format is the same as batch-generate endpoint. Returns: - A JSON object containing the array of generated and saved personas with their IDs + A JSON object containing the array of generated and saved personas with their IDs and task_id """ user_id = get_jwt_identity() data = await request.get_json() or {} @@ -521,91 +541,108 @@ async def batch_generate_and_save_personas(): return jsonify({"error": "Invalid count", "message": "Count must be between 1 and 10"}), 400 customizations = data.get('customizations', []) - temperature = data.get('temperature', 0.7) + temperature = data.get('temperature', 1.0) try: - # Prepare customization prompts for each persona - generation_tasks = [] - for i in range(count): - # Use a customization if available for this index - custom_prompt = None - if i < len(customizations): - custom_data = customizations[i] - custom_prompt = customize_persona_prompt( - age_range=custom_data.get('age_range'), - gender=custom_data.get('gender'), - occupation_type=custom_data.get('occupation_type'), - education_level=custom_data.get('education_level'), - location_type=custom_data.get('location_type'), - personality_traits=custom_data.get('personality_traits'), - interests=custom_data.get('interests'), - audience_brief=custom_data.get('audience_brief') - ) + # Register current task for cancellation + async with CancellableTask("persona_generation", user_id, {"count": count, "type": "batch_and_save"}) as task_id: + # Prepare customization prompts for each persona + generation_tasks = [] + for i in range(count): + # Check for cancellation before each persona setup + if asyncio.current_task().cancelled(): + raise asyncio.CancelledError("Task was cancelled") + + # Use a customization if available for this index + custom_prompt = None + if i < len(customizations): + custom_data = customizations[i] + custom_prompt = customize_persona_prompt( + age_range=custom_data.get('age_range'), + gender=custom_data.get('gender'), + occupation_type=custom_data.get('occupation_type'), + education_level=custom_data.get('education_level'), + location_type=custom_data.get('location_type'), + personality_traits=custom_data.get('personality_traits'), + interests=custom_data.get('interests'), + audience_brief=custom_data.get('audience_brief') + ) + + # Add to the list of tasks to be executed + generation_tasks.append({ + 'prompt_customization': custom_prompt, + 'temperature': temperature + }) - # Add to the list of tasks to be executed - generation_tasks.append({ - 'prompt_customization': custom_prompt, - 'temperature': temperature - }) - - # Generate personas using asyncio.gather for concurrent async execution - try: - generation_coroutines = [ - generate_persona( - task['prompt_customization'], - None, # No basic_persona for this endpoint - task['temperature'] - ) for task in generation_tasks - ] - - # Execute all persona generations concurrently - generated_personas = await asyncio.gather(*generation_coroutines) - - except Exception as exc: - current_app.logger.error(f"Persona generation task failed with error: {exc}") - raise PersonaGenerationError(f"Failed to generate one of the personas: {str(exc)}") - - # Save all generated personas to the database - personas = [] - persona_ids = [] - for persona_data in generated_personas: - # Generate AI summary for each persona + # Generate personas using asyncio.gather for concurrent async execution try: - summary_data = await generate_persona_summary( - persona_data=persona_data, - temperature=temperature - ) + generation_coroutines = [ + generate_persona( + task['prompt_customization'], + None, # No basic_persona for this endpoint + task['temperature'] + ) for task in generation_tasks + ] - # Add summary fields to the persona data - persona_data['aiSynthesizedBio'] = summary_data['aiSynthesizedBio'] - persona_data['qualitativeAttributes'] = summary_data['qualitativeAttributes'] - persona_data['topPersonalityTraits'] = summary_data['topPersonalityTraits'] + # Execute all persona generations concurrently + generated_personas = await asyncio.gather(*generation_coroutines) - print(f"Generated summary for persona {persona_data.get('name', 'Unknown')}") - - except Exception as summary_error: - # Log the error but don't fail the entire persona creation - current_app.logger.warning(f"Failed to generate summary for persona: {str(summary_error)}") - print(f"Warning: Could not generate summary for persona: {str(summary_error)}") + except asyncio.CancelledError: + current_app.logger.info(f"Batch persona generation and save cancelled for user {user_id}") + return jsonify({"error": "Generation cancelled", "message": "Persona generation was cancelled by user"}), 499 + except Exception as exc: + current_app.logger.error(f"Persona generation task failed with error: {exc}") + raise PersonaGenerationError(f"Failed to generate one of the personas: {str(exc)}") - # Remove generated ID before saving - if 'id' in persona_data: - del persona_data['id'] + # Save all generated personas to the database + personas = [] + persona_ids = [] + for persona_data in generated_personas: + # Check for cancellation before processing each persona + if asyncio.current_task().cancelled(): + raise asyncio.CancelledError("Task was cancelled") - # Save to database - persona_id = await Persona.create(persona_data, user_id) + # Generate AI summary for each persona + try: + summary_data = await generate_persona_summary( + persona_data=persona_data, + temperature=temperature + ) + + # Add summary fields to the persona data + persona_data['aiSynthesizedBio'] = summary_data['aiSynthesizedBio'] + persona_data['qualitativeAttributes'] = summary_data['qualitativeAttributes'] + persona_data['topPersonalityTraits'] = summary_data['topPersonalityTraits'] + + print(f"Generated summary for persona {persona_data.get('name', 'Unknown')}") + + except Exception as summary_error: + # Log the error but don't fail the entire persona creation + current_app.logger.warning(f"Failed to generate summary for persona: {str(summary_error)}") + print(f"Warning: Could not generate summary for persona: {str(summary_error)}") + + # Remove generated ID before saving + if 'id' in persona_data: + del persona_data['id'] + + # Save to database + persona_id = await Persona.create(persona_data, user_id) + + # Add database ID to the response + persona_data['_id'] = str(persona_id) + personas.append(persona_data) + persona_ids.append(str(persona_id)) - # Add database ID to the response - persona_data['_id'] = str(persona_id) - personas.append(persona_data) - persona_ids.append(str(persona_id)) - - return jsonify({ - "message": f"Successfully generated and saved {len(personas)} personas", - "personas": personas, - "persona_ids": persona_ids - }), 201 + return jsonify({ + "message": f"Successfully generated and saved {len(personas)} personas", + "personas": personas, + "persona_ids": persona_ids, + "task_id": task_id + }), 201 + except asyncio.CancelledError: + current_app.logger.info(f"Batch persona generation and save cancelled for user {user_id}") + return jsonify({"error": "Generation cancelled", "message": "Persona generation was cancelled by user"}), 499 except PersonaGenerationError as e: current_app.logger.error(f"AI Persona batch generation and save error: {str(e)}") return jsonify({"error": "Failed to generate and save personas", "message": str(e)}), 500 @@ -655,9 +692,9 @@ async def generate_summary_for_persona(): "message": f"Persona data is missing required fields: {', '.join(missing_fields)}" }), 400 - temperature = data.get('temperature', 0.7) - if not (0 <= temperature <= 1): - temperature = 0.7 + temperature = data.get('temperature', 1.0) + if not (0 <= temperature <= 1.5): + temperature = 1.0 try: # Generate the summary @@ -717,9 +754,9 @@ async def enhance_audience_brief_endpoint(): if len(research_objective.strip()) < 10: return jsonify({"error": "Research objective too short", "message": "Research objective must be at least 10 characters long"}), 400 - temperature = data.get('temperature', 0.7) - if not (0 <= temperature <= 1): - temperature = 0.7 + temperature = data.get('temperature', 1.0) + if not (0 <= temperature <= 1.5): + temperature = 1.0 try: # Generate enhancement suggestions @@ -775,9 +812,9 @@ async def batch_generate_summaries(): print(f"❌ Backend: Invalid persona_ids type: {type(persona_ids)}") return jsonify({"error": "Invalid persona IDs", "message": "persona_ids must be an array"}), 400 - temperature = data.get('temperature', 0.7) - if not (0 <= temperature <= 1): - temperature = 0.7 + temperature = data.get('temperature', 1.0) + if not (0 <= temperature <= 1.5): + temperature = 1.0 llm_model = data.get('llm_model', 'gemini-2.5-pro') # Optional parameter with default @@ -786,114 +823,168 @@ async def batch_generate_summaries(): current_app.logger.info(f"Batch generating summaries for {len(persona_ids)} personas using model: {llm_model}") try: - # Fetch all persona data first - personas_data = [] - missing_personas = [] - - for persona_id in persona_ids: - try: - persona = await Persona.find_by_id(persona_id) - if persona: - personas_data.append(persona) - else: - missing_personas.append(persona_id) - except Exception as e: - current_app.logger.warning(f"Failed to fetch persona {persona_id}: {str(e)}") - missing_personas.append(persona_id) - - if not personas_data: - return jsonify({ - "error": "No valid personas found", - "message": "None of the provided persona IDs could be found" - }), 404 - - # Process personas in batches of 10 - batch_size = 10 - successful_summaries = [] - failed_summaries = [] - - async def process_persona_summary(persona_data): - """Helper function to process a single persona summary""" - try: - persona_name = persona_data.get('name', 'Unknown') - print(f"✅ Backend: Successfully generated summary for '{persona_name}' using model: {llm_model}") - - summary = await generate_persona_download_summary( - persona_data=persona_data, - temperature=temperature, - llm_model=llm_model + # Register current task for cancellation + async with CancellableTask("persona_summary_generation", user_id, {"count": len(persona_ids), "type": "batch_summaries"}) as task_id: + + # Emit task_started event via WebSocket for immediate frontend tracking + from app.websocket_manager_async import get_async_websocket_manager + websocket_manager = get_async_websocket_manager() + if user_id: + await websocket_manager.emit_to_user( + user_id, + 'task_started', + { + 'task_id': task_id, + 'task_type': 'persona_summary_generation', + 'message': f'Started generating summaries for {len(persona_ids)} personas' + } ) - return { - 'success': True, - 'persona_id': persona_data.get('_id', persona_data.get('id')), - 'persona_name': persona_data.get('name', 'Unknown'), - 'summary': summary - } - except Exception as e: - return { - 'success': False, - 'persona_id': persona_data.get('_id', persona_data.get('id')), - 'persona_name': persona_data.get('name', 'Unknown'), - 'error': str(e) - } - - # Process in batches - for i in range(0, len(personas_data), batch_size): - batch = personas_data[i:i + batch_size] - current_app.logger.info(f"Processing batch {i//batch_size + 1}: {len(batch)} personas") - # Process this batch using asyncio - batch_tasks = [process_persona_summary(persona) for persona in batch] - batch_results = await asyncio.gather(*batch_tasks, return_exceptions=True) + # Fetch all persona data first + personas_data = [] + missing_personas = [] - # Collect results - for result in batch_results: - if isinstance(result, Exception): - failed_summaries.append({ + for persona_id in persona_ids: + # Check for cancellation before each persona fetch + if asyncio.current_task().cancelled(): + raise asyncio.CancelledError("Task was cancelled") + + try: + persona = await Persona.find_by_id(persona_id) + if persona: + personas_data.append(persona) + else: + missing_personas.append(persona_id) + except Exception as e: + current_app.logger.warning(f"Failed to fetch persona {persona_id}: {str(e)}") + missing_personas.append(persona_id) + + if not personas_data: + return jsonify({ + "error": "No valid personas found", + "message": "None of the provided persona IDs could be found" + }), 404 + + # Process personas in batches of 10 + batch_size = 10 + successful_summaries = [] + failed_summaries = [] + + async def process_persona_summary(persona_data): + """Helper function to process a single persona summary""" + try: + # Check for cancellation before processing each summary + if asyncio.current_task().cancelled(): + raise asyncio.CancelledError("Task was cancelled") + + persona_name = persona_data.get('name', 'Unknown') + print(f"✅ Backend: Successfully generated summary for '{persona_name}' using model: {llm_model}") + + summary = await generate_persona_download_summary( + persona_data=persona_data, + temperature=temperature, + llm_model=llm_model + ) + return { + 'success': True, + 'persona_id': persona_data.get('_id', persona_data.get('id')), + 'persona_name': persona_data.get('name', 'Unknown'), + 'summary': summary + } + except asyncio.CancelledError: + raise # Re-raise cancellation + except Exception as e: + return { 'success': False, - 'error': str(result), - 'persona_name': 'Unknown' - }) - current_app.logger.error(f"Failed to generate summary: {result}") - elif result['success']: - successful_summaries.append(result) - else: - failed_summaries.append(result) - current_app.logger.error(f"Failed to generate summary for persona {result['persona_name']}: {result['error']}") - - # Prepare response - total_requested = len(persona_ids) - total_found = len(personas_data) - total_successful = len(successful_summaries) - total_failed = len(failed_summaries) + len(missing_personas) - - response_data = { - "message": f"Processed {total_successful} of {total_requested} personas successfully", - "summary_stats": { - "total_requested": total_requested, - "total_found": total_found, - "total_successful": total_successful, - "total_failed": total_failed, - "missing_personas": len(missing_personas) - }, - "summaries": successful_summaries - } - - # Add error details if there were failures - if failed_summaries or missing_personas: - response_data["errors"] = { - "failed_summaries": failed_summaries, - "missing_personas": missing_personas + 'persona_id': persona_data.get('_id', persona_data.get('id')), + 'persona_name': persona_data.get('name', 'Unknown'), + 'error': str(e) + } + + # Process in batches + for i in range(0, len(personas_data), batch_size): + # Check for cancellation before each batch + if asyncio.current_task().cancelled(): + raise asyncio.CancelledError("Task was cancelled") + + batch = personas_data[i:i + batch_size] + current_app.logger.info(f"Processing batch {i//batch_size + 1}: {len(batch)} personas") + + # Process this batch using asyncio + batch_tasks = [process_persona_summary(persona) for persona in batch] + try: + batch_results = await asyncio.gather(*batch_tasks, return_exceptions=True) + except asyncio.CancelledError: + raise # Re-raise cancellation + + # Collect results + for result in batch_results: + if isinstance(result, Exception): + if isinstance(result, asyncio.CancelledError): + raise result # Re-raise cancellation + failed_summaries.append({ + 'success': False, + 'error': str(result), + 'persona_name': 'Unknown' + }) + current_app.logger.error(f"Failed to generate summary: {result}") + elif result['success']: + successful_summaries.append(result) + else: + failed_summaries.append(result) + current_app.logger.error(f"Failed to generate summary for persona {result['persona_name']}: {result['error']}") + + # Prepare response + total_requested = len(persona_ids) + total_found = len(personas_data) + total_successful = len(successful_summaries) + total_failed = len(failed_summaries) + len(missing_personas) + + response_data = { + "message": f"Processed {total_successful} of {total_requested} personas successfully", + "summary_stats": { + "total_requested": total_requested, + "total_found": total_found, + "total_successful": total_successful, + "total_failed": total_failed, + "missing_personas": len(missing_personas) + }, + "summaries": successful_summaries, + "task_id": task_id } + + # Add error details if there were failures + if failed_summaries or missing_personas: + response_data["errors"] = { + "failed_summaries": failed_summaries, + "missing_personas": missing_personas + } + + # Emit completion event via WebSocket + if user_id: + await websocket_manager.emit_to_user( + user_id, + 'task_completed', + { + 'task_id': task_id, + 'task_type': 'persona_summary_generation', + 'message': f'Successfully generated summaries for {total_successful} of {total_requested} personas', + 'summaries_created': total_successful, + 'errors': total_failed + } + ) + + # Determine appropriate status code + if total_successful == 0: + return jsonify(response_data), 500 # Complete failure + elif total_successful < total_requested: + return jsonify(response_data), 206 # Partial success + else: + return jsonify(response_data), 200 # Complete success - # Determine appropriate status code - if total_successful == 0: - return jsonify(response_data), 500 # Complete failure - elif total_successful < total_requested: - return jsonify(response_data), 206 # Partial success - else: - return jsonify(response_data), 200 # Complete success - + except asyncio.CancelledError: + current_app.logger.info(f"Batch summary generation cancelled for user {user_id}") + return jsonify({"error": "Generation cancelled", "message": "Batch summary generation was cancelled by user"}), 499 except PersonaGenerationError as e: print(f"❌ Backend: Batch summary generation error: {str(e)}") current_app.logger.error(f"Batch summary generation error: {str(e)}") @@ -906,6 +997,257 @@ async def batch_generate_summaries(): return jsonify({"error": "Internal server error", "message": f"An unexpected error occurred: {str(e)}"}), 500 +@ai_personas_bp.route('/generate-personas-full', methods=['POST']) +@jwt_required() +async def generate_personas_full(): + """ + Unified endpoint for complete persona generation with cancellation support. + + Combines the two-stage generation process (basic profiles + completion) into a single + endpoint with proper task management and cancellation checkpoints. + + Request body: + { + "audience_brief": "A detailed description of the audience context...", + "research_objective": "Optional research objective...", + "count": 5, + "temperature": 0.8, + "customer_data_session_id": "optional_session_id", + "llm_model": "gemini-2.5-pro", + "target_folder_id": "optional_folder_id" + } + + Returns: + JSON object with task_id and generated personas (when complete) + """ + user_id = get_jwt_identity() + data = await request.get_json() or {} + + # Extract and validate parameters + audience_brief = data.get('audience_brief') + if not audience_brief or len(audience_brief.strip()) < 10: + return jsonify({"error": "Missing or invalid audience brief", "message": "Audience brief must be at least 10 characters"}), 400 + + research_objective = data.get('research_objective') + count = data.get('count', 5) + if count < 1 or count > 10: + return jsonify({"error": "Invalid count", "message": "Count must be between 1 and 10"}), 400 + + temperature = data.get('temperature', 1.0) + if not (0 <= temperature <= 1.5): + temperature = 1.0 + + customer_data_session_id = data.get('customer_data_session_id') + llm_model = data.get('llm_model', 'gemini-2.5-pro') + target_folder_id = data.get('target_folder_id') + + try: + # Register current task for cancellation with comprehensive metadata + async with CancellableTask("persona_full_generation", user_id, { + "count": count, + "type": "unified_generation", + "llm_model": llm_model, + "target_folder_id": target_folder_id + }) as task_id: + + current_app.logger.info(f"Starting unified persona generation for {count} personas using {llm_model}") + print(f"🔄 Backend: Unified generation started - Task ID: {task_id}") + + # Return task_id immediately so frontend can start cancellation tracking + from app.websocket_manager_async import get_async_websocket_manager + websocket_manager = get_async_websocket_manager() + if user_id: + await websocket_manager.emit_to_user( + user_id, + 'task_started', + { + 'task_id': task_id, + 'task_type': 'persona_full_generation', + 'message': f'Started generating {count} personas' + } + ) + + # Stage 1: Generate basic profiles with cancellation check + if asyncio.current_task().cancelled(): + raise asyncio.CancelledError("Task was cancelled") + + print(f"🔄 Backend: Stage 1 - Generating {count} basic profiles...") + basic_profiles = await generate_basic_personas( + audience_brief=audience_brief, + research_objective=research_objective, + count=count, + temperature=temperature, # Use temperature from request + customer_data_session_id=customer_data_session_id, + llm_model=llm_model + ) + + print(f"✅ Backend: Stage 1 complete - {len(basic_profiles)} basic profiles generated") + + # Stage 2: Complete personas in parallel with cancellation support + completed_personas = [] + failed_personas = [] + + print(f"🔄 Backend: Stage 2 - Completing {len(basic_profiles)} personas in parallel...") + + async def complete_single_persona(i: int, basic_profile: dict): + """Complete a single persona with cancellation checks.""" + try: + # Check for cancellation before starting + if asyncio.current_task().cancelled(): + raise asyncio.CancelledError("Task was cancelled") + + print(f"🔄 Backend: Completing persona {i+1}/{len(basic_profiles)}: {basic_profile.get('name', 'Unknown')}") + + # Complete the persona + persona_data = await generate_persona( + basic_persona=basic_profile, + temperature=temperature, + customer_data_session_id=customer_data_session_id, + llm_model=llm_model + ) + + # Check cancellation before summary generation + if asyncio.current_task().cancelled(): + raise asyncio.CancelledError("Task was cancelled") + + # Generate AI summary + try: + summary_data = await generate_persona_summary( + persona_data=persona_data, + temperature=temperature, + llm_model=llm_model + ) + + persona_data['aiSynthesizedBio'] = summary_data['aiSynthesizedBio'] + persona_data['qualitativeAttributes'] = summary_data['qualitativeAttributes'] + persona_data['topPersonalityTraits'] = summary_data['topPersonalityTraits'] + + except Exception as summary_error: + current_app.logger.warning(f"Failed to generate summary for persona: {str(summary_error)}") + + # Add generation prompts + if audience_brief: + persona_data['audience_brief'] = audience_brief + if research_objective: + persona_data['research_objective'] = research_objective + + # Check cancellation before database save + if asyncio.current_task().cancelled(): + raise asyncio.CancelledError("Task was cancelled") + + # Remove generated ID before saving + if 'id' in persona_data: + del persona_data['id'] + + # Save to database + persona_id = await Persona.create(persona_data, user_id) + persona_data['_id'] = str(persona_id) + + print(f"✅ Backend: Persona {i+1}/{len(basic_profiles)} completed: {persona_data.get('name')}") + return {'success': True, 'persona': persona_data, 'index': i} + + except asyncio.CancelledError: + raise # Re-raise cancellation + except Exception as persona_error: + print(f"❌ Backend: Failed to complete persona {i+1}: {persona_error}") + return { + 'success': False, + 'index': i, + 'name': basic_profile.get('name', f'Persona {i+1}'), + 'error': str(persona_error) + } + + # Execute all persona completions in parallel + try: + completion_tasks = [ + complete_single_persona(i, profile) + for i, profile in enumerate(basic_profiles) + ] + + # Wait for all personas to complete or fail + results = await asyncio.gather(*completion_tasks, return_exceptions=True) + + # Process results + for result in results: + if isinstance(result, asyncio.CancelledError): + raise result # Re-raise cancellation + elif isinstance(result, Exception): + failed_personas.append({ + 'index': -1, + 'name': 'Unknown', + 'error': str(result) + }) + elif result['success']: + completed_personas.append(result['persona']) + else: + failed_personas.append({ + 'index': result['index'], + 'name': result['name'], + 'error': result['error'] + }) + + except asyncio.CancelledError: + raise # Re-raise cancellation + + print(f"✅ Backend: Stage 2 complete - {len(completed_personas)} personas completed, {len(failed_personas)} failed") + + # Check cancellation before folder assignment + if asyncio.current_task().cancelled(): + raise asyncio.CancelledError("Task was cancelled") + + # Add personas to target folder if specified + if target_folder_id and completed_personas: + try: + from app.models.folder import Folder + persona_ids = [p['_id'] for p in completed_personas] + await Folder.add_personas_batch(target_folder_id, persona_ids) + print(f"✅ Backend: Added {len(persona_ids)} personas to folder {target_folder_id}") + except Exception as folder_error: + current_app.logger.warning(f"Failed to add personas to folder: {folder_error}") + + # Clean up customer data if provided + if customer_data_session_id: + try: + customer_data_service.cleanup_session(customer_data_session_id) + except Exception as cleanup_error: + current_app.logger.warning(f"Failed to cleanup customer data: {cleanup_error}") + + print(f"🎉 Backend: Unified generation complete - {len(completed_personas)} personas created") + + # Emit completion event via WebSocket + if user_id: + await websocket_manager.emit_to_user( + user_id, + 'task_completed', + { + 'task_id': task_id, + 'task_type': 'persona_full_generation', + 'message': f'Successfully generated {len(completed_personas)} personas', + 'personas_created': len(completed_personas), + 'errors': len(failed_personas) + } + ) + + return jsonify({ + "message": f"Successfully generated and saved {len(completed_personas)} personas using {llm_model}", + "personas": completed_personas, + "persona_ids": [p['_id'] for p in completed_personas], + "task_id": task_id, + "errors": failed_personas if failed_personas else None, + "partial_success": len(failed_personas) > 0 and len(completed_personas) > 0 + }), 201 + + except asyncio.CancelledError: + current_app.logger.info(f"Unified persona generation cancelled for user {user_id}") + return jsonify({"error": "Generation cancelled", "message": "Persona generation was cancelled by user"}), 499 + except PersonaGenerationError as e: + current_app.logger.error(f"Unified persona generation error: {str(e)}") + return jsonify({"error": "Failed to generate personas", "message": str(e)}), 500 + except Exception as e: + current_app.logger.error(f"Unexpected error in unified persona generation: {str(e)}") + return jsonify({"error": "Internal server error", "message": "An unexpected error occurred"}), 500 + + @ai_personas_bp.route('/upload-customer-data', methods=['POST']) @jwt_required() async def upload_customer_data(): diff --git a/backend/app/routes/focus_group_ai.py b/backend/app/routes/focus_group_ai.py index 67014c70..3c2df9df 100644 --- a/backend/app/routes/focus_group_ai.py +++ b/backend/app/routes/focus_group_ai.py @@ -9,6 +9,7 @@ from app.auth.quart_jwt import jwt_required, get_jwt_identity from typing import Dict, List, Any import time import concurrent.futures +import asyncio from app.services.focus_group_response_service import ( generate_persona_response, @@ -20,6 +21,7 @@ from app.services.key_theme_service import ( KeyThemeService, KeyThemeServiceError ) +from app.services.task_manager import CancellableTask from app.services.ai_moderator_service import AIModeratorService from app.services.autonomous_conversation_controller import AutonomousConversationController from app.services.conversation_decision_service import ConversationDecisionService, ConversationDecisionError @@ -299,55 +301,99 @@ async def generate_key_themes(): if not focus_group: return jsonify({"error": "Focus group not found"}), 404 - # Get the LLM model for this focus group - llm_model = focus_group.get('llm_model') - - # Generate key themes + # Get user_id for task tracking (optional for development mode) + user_id = None try: - themes = await KeyThemeService.generate_key_themes( - focus_group_id=focus_group_id, - temperature=temperature, - llm_model=llm_model - ) + user_id = get_jwt_identity() + except: + pass # JWT is optional in development + + # Register current task for cancellation + async with CancellableTask("key_themes_generation", user_id, {"focus_group_id": focus_group_id}) as task_id: - # Log success - current_app.logger.info(f"Generated {len(themes)} key themes for focus group {focus_group_id}") + # Emit task_started event via WebSocket for immediate frontend tracking + from app.websocket_manager_async import get_async_websocket_manager + websocket_manager = get_async_websocket_manager() + if user_id: + await websocket_manager.emit_to_user( + user_id, + 'task_started', + { + 'task_id': task_id, + 'task_type': 'key_themes_generation', + 'message': f'Started generating key themes for focus group {focus_group_id}' + } + ) - # Save themes to database - theme_ids = await FocusGroup.add_generated_themes(focus_group_id, themes) + # Get the LLM model for this focus group + llm_model = focus_group.get('llm_model') - if not theme_ids: - current_app.logger.error("Failed to save themes to database") + # Generate key themes + try: + themes = await KeyThemeService.generate_key_themes( + focus_group_id=focus_group_id, + temperature=temperature, + llm_model=llm_model + ) + + # Log success + current_app.logger.info(f"Generated {len(themes)} key themes for focus group {focus_group_id}") + + # Save themes to database + theme_ids = await FocusGroup.add_generated_themes(focus_group_id, themes) + + if not theme_ids: + current_app.logger.error("Failed to save themes to database") + return jsonify({ + "error": "Failed to save themes", + "message": "The themes were generated but could not be saved to the database" + }), 500 + + # Format the themes for response + formatted_themes = [] + for i, theme in enumerate(themes): + if i < len(theme_ids): + formatted_themes.append({ + "id": theme_ids[i], + "title": theme["title"], + "description": theme["description"], + "quotes": theme.get("quotes", []), + "source": "generated" + }) + + # Emit completion event via WebSocket + if user_id: + await websocket_manager.emit_to_user( + user_id, + 'task_completed', + { + 'task_id': task_id, + 'task_type': 'key_themes_generation', + 'message': f'Successfully generated {len(formatted_themes)} key themes', + 'themes_created': len(formatted_themes) + } + ) + return jsonify({ - "error": "Failed to save themes", - "message": "The themes were generated but could not be saved to the database" + "message": "Key themes generated successfully", + "themes": formatted_themes, + "focus_group_id": focus_group_id, + "task_id": task_id + }), 200 + + except KeyThemeServiceError as e: + current_app.logger.error(f"Error generating key themes: {str(e)}") + return jsonify({ + "error": "Failed to generate key themes", + "message": str(e) }), 500 - # Format the themes for response - formatted_themes = [] - for i, theme in enumerate(themes): - if i < len(theme_ids): - formatted_themes.append({ - "id": theme_ids[i], - "title": theme["title"], - "description": theme["description"], - "quotes": theme.get("quotes", []), - "source": "generated" - }) - - return jsonify({ - "message": "Key themes generated successfully", - "themes": formatted_themes, - "focus_group_id": focus_group_id - }), 200 - - except KeyThemeServiceError as e: - current_app.logger.error(f"Error generating key themes: {str(e)}") - return jsonify({ - "error": "Failed to generate key themes", - "message": str(e) - }), 500 - + except asyncio.CancelledError: + current_app.logger.info(f"Key themes generation cancelled for focus group {focus_group_id}") + return jsonify({ + "error": "Generation cancelled", + "message": "Key themes generation was cancelled by user" + }), 499 except Exception as e: current_app.logger.error(f"Unexpected error in key theme generation: {str(e)}") return jsonify({ diff --git a/backend/app/routes/focus_groups.py b/backend/app/routes/focus_groups.py index 03af4e6e..d581fdd2 100644 --- a/backend/app/routes/focus_groups.py +++ b/backend/app/routes/focus_groups.py @@ -4,10 +4,12 @@ from app.models.focus_group import FocusGroup from app.models.persona import Persona from app.services.focus_group_service import FocusGroupService from app.services.image_description_service import ImageDescriptionService, ImageDescriptionError +from app.services.task_manager import CancellableTask from bson import ObjectId import datetime import json import os +import asyncio import uuid import tempfile from werkzeug.utils import secure_filename @@ -806,53 +808,98 @@ async def generate_discussion_guide(focus_group_id=None): logger.info(f"Generating discussion guide for: '{focus_group_name}' (duration: {duration}min)") - # Add topic as a discussion topic if not already there - if discussion_topics and isinstance(discussion_topics, str): - # Convert to a specific topic if it's from the selection dropdown - topic_mapping = { - 'product-feedback': 'Product Feedback', - 'creative-testing': 'Creative Testing', - 'messaging-evaluation': 'Messaging Evaluation', - 'user-experience': 'User Experience', - 'market-research': 'Market Research' - } - formatted_topic = topic_mapping.get(discussion_topics, discussion_topics) - else: - formatted_topic = 'General Discussion' + # Get user_id for task tracking (optional for development mode) + user_id = None + try: + user_id = get_jwt_identity() + except: + pass # JWT is optional in development - # Get the LLM model for this focus group if it exists - llm_model = None - if focus_group_id: - try: - focus_group = await FocusGroup.find_by_id(focus_group_id) - if focus_group: - llm_model = focus_group.get('llm_model') - logger.info(f"Using LLM model for focus group {focus_group_id}: {llm_model}") - except Exception as e: - logger.warning(f"Could not retrieve LLM model for focus group {focus_group_id}: {e}") + # Register current task for cancellation + async with CancellableTask("discussion_guide_generation", user_id, {"focus_group_name": focus_group_name, "focus_group_id": focus_group_id}) as task_id: + + # Emit task_started event via WebSocket for immediate frontend tracking + from app.websocket_manager_async import get_async_websocket_manager + websocket_manager = get_async_websocket_manager() + if user_id: + await websocket_manager.emit_to_user( + user_id, + 'task_started', + { + 'task_id': task_id, + 'task_type': 'discussion_guide_generation', + 'message': f'Started generating discussion guide for {focus_group_name}' + } + ) + + # Add topic as a discussion topic if not already there + if discussion_topics and isinstance(discussion_topics, str): + # Convert to a specific topic if it's from the selection dropdown + topic_mapping = { + 'product-feedback': 'Product Feedback', + 'creative-testing': 'Creative Testing', + 'messaging-evaluation': 'Messaging Evaluation', + 'user-experience': 'User Experience', + 'market-research': 'Market Research' + } + formatted_topic = topic_mapping.get(discussion_topics, discussion_topics) + else: + formatted_topic = 'General Discussion' + + # Get the LLM model for this focus group if it exists + llm_model = None + if focus_group_id: + try: + focus_group = await FocusGroup.find_by_id(focus_group_id) + if focus_group: + llm_model = focus_group.get('llm_model') + logger.info(f"Using LLM model for focus group {focus_group_id}: {llm_model}") + except Exception as e: + logger.warning(f"Could not retrieve LLM model for focus group {focus_group_id}: {e}") + + # Use default model from request data if provided + if not llm_model: + llm_model = data.get('llm_model') + + # Generate the discussion guide + discussion_guide = await FocusGroupService.generate_discussion_guide( + focus_group_name=focus_group_name, + research_brief=research_brief, + discussion_topics=formatted_topic, + duration=duration, + temperature=0.7, + focus_group_id=focus_group_id, + llm_model=llm_model + ) + + # Emit completion event via WebSocket + if user_id: + await websocket_manager.emit_to_user( + user_id, + 'task_completed', + { + 'task_id': task_id, + 'task_type': 'discussion_guide_generation', + 'message': f'Successfully generated discussion guide for {focus_group_name}' + } + ) + + logger.info(f"Discussion guide successfully generated for '{focus_group_name}'") + return jsonify({ + "message": "Discussion guide generated successfully", + "discussionGuide": discussion_guide, + "success": True, + "task_id": task_id + }), 200 - # Use default model from request data if provided - if not llm_model: - llm_model = data.get('llm_model') - - # Generate the discussion guide - discussion_guide = await FocusGroupService.generate_discussion_guide( - focus_group_name=focus_group_name, - research_brief=research_brief, - discussion_topics=formatted_topic, - duration=duration, - temperature=0.7, - focus_group_id=focus_group_id, - llm_model=llm_model - ) - - logger.info(f"Discussion guide successfully generated for '{focus_group_name}'") + except asyncio.CancelledError: + logger.info(f"Discussion guide generation cancelled for focus group: {data.get('name', 'Unknown') if 'data' in locals() else 'Unknown'}") return jsonify({ - "message": "Discussion guide generated successfully", - "discussionGuide": discussion_guide, - "success": True - }), 200 - + "error": "Generation cancelled", + "details": "Discussion guide generation was cancelled by user", + "can_retry": True, + "error_type": "cancelled" + }), 499 except Exception as e: error_msg = str(e) logger.error(f"Discussion guide generation failed with error: {error_msg}") diff --git a/backend/app/routes/personas.py b/backend/app/routes/personas.py index 8d70b67e..f529ad51 100644 --- a/backend/app/routes/personas.py +++ b/backend/app/routes/personas.py @@ -3,8 +3,10 @@ from app.auth.quart_jwt import jwt_required, get_jwt_identity from app.models.persona import Persona from app.services.persona_export_service import PersonaExportService from app.services.persona_modification_service import PersonaModificationService, PersonaModificationError +from app.services.task_manager import CancellableTask from bson import ObjectId import datetime +import asyncio # Helper function to make MongoDB documents JSON serializable def make_serializable(obj): @@ -186,24 +188,67 @@ async def modify_persona_with_ai(persona_id): print(f"🤖 Backend: {mode_text.title()} persona {persona_id} with {llm_model}") print(f"📝 Modification prompt: {modification_prompt[:100]}...") - # Call the modification service - modified_persona_data = await PersonaModificationService.modify_persona( - persona_id=persona_id, - modification_prompt=modification_prompt, - llm_model=llm_model, - reasoning_effort=reasoning_effort, - verbosity=verbosity, - preview_only=preview_only - ) + # Get user_id for task tracking (optional for development mode) + user_id = None + try: + user_id = get_jwt_identity() + except: + pass # JWT is optional in development - success_message = "Persona preview generated successfully" if preview_only else "Persona modified successfully" + # Register current task for cancellation + async with CancellableTask("persona_modification", user_id, {"persona_id": persona_id, "preview_only": preview_only}) as task_id: + + # Emit task_started event via WebSocket for immediate frontend tracking + from app.websocket_manager_async import get_async_websocket_manager + websocket_manager = get_async_websocket_manager() + if user_id: + await websocket_manager.emit_to_user( + user_id, + 'task_started', + { + 'task_id': task_id, + 'task_type': 'persona_modification', + 'message': f'Started {"previewing" if preview_only else "modifying"} persona {persona_id}' + } + ) + + # Call the modification service + modified_persona_data = await PersonaModificationService.modify_persona( + persona_id=persona_id, + modification_prompt=modification_prompt, + llm_model=llm_model, + reasoning_effort=reasoning_effort, + verbosity=verbosity, + preview_only=preview_only + ) + + # Emit completion event via WebSocket + if user_id: + await websocket_manager.emit_to_user( + user_id, + 'task_completed', + { + 'task_id': task_id, + 'task_type': 'persona_modification', + 'message': f'Successfully {"previewed" if preview_only else "modified"} persona' + } + ) + + success_message = "Persona preview generated successfully" if preview_only else "Persona modified successfully" + return jsonify({ + "success": True, + "message": success_message, + "persona": make_serializable(modified_persona_data), + "preview_only": preview_only, + "task_id": task_id + }), 200 + + except asyncio.CancelledError: + print(f"⏹️ Persona modification cancelled for persona {persona_id}") return jsonify({ - "success": True, - "message": success_message, - "persona": make_serializable(modified_persona_data), - "preview_only": preview_only - }), 200 - + "error": "Generation cancelled", + "message": "Persona modification was cancelled by user" + }), 499 except PersonaModificationError as e: print(f"❌ Persona modification error: {e}") return jsonify({"error": str(e)}), 400 diff --git a/backend/app/routes/tasks.py b/backend/app/routes/tasks.py new file mode 100644 index 00000000..b6ef11e8 --- /dev/null +++ b/backend/app/routes/tasks.py @@ -0,0 +1,129 @@ +""" +Task management routes for handling cancellable operations. +""" + +from quart import Blueprint, jsonify, request +from app.services.task_manager import get_task_manager +from app.websocket_manager_async import get_async_websocket_manager +import logging + +logger = logging.getLogger(__name__) + +tasks_bp = Blueprint('tasks', __name__) + + +@tasks_bp.route('/', methods=['DELETE']) +async def cancel_task(task_id: str): + """ + Cancel a running task by its ID. + + Args: + task_id: The unique identifier of the task to cancel + + Returns: + JSON response indicating success or failure + """ + try: + task_manager = get_task_manager() + + # Get task info before cancellation for WebSocket notification + task_info = await task_manager.get_task_info(task_id) + + # Attempt to cancel the task + cancelled = await task_manager.cancel_task(task_id) + + if not cancelled: + return jsonify({ + 'error': 'Task not found or already completed', + 'task_id': task_id + }), 404 + + # Send WebSocket notification about cancellation + websocket_manager = get_async_websocket_manager() + if task_info and task_info.user_id: + await websocket_manager.emit_to_user( + task_info.user_id, + 'task_cancelled', + { + 'task_id': task_id, + 'task_type': task_info.task_type, + 'message': f'{task_info.task_type.replace("_", " ").title()} cancelled successfully' + } + ) + + logger.info(f"Successfully cancelled task {task_id}") + + return jsonify({ + 'message': 'Task cancelled successfully', + 'task_id': task_id, + 'task_type': task_info.task_type if task_info else None + }), 200 + + except Exception as e: + logger.error(f"Error cancelling task {task_id}: {str(e)}") + return jsonify({ + 'error': 'Internal server error while cancelling task', + 'task_id': task_id + }), 500 + + +@tasks_bp.route('/user/', methods=['GET']) +async def get_user_tasks(user_id: str): + """ + Get all active tasks for a specific user. + + Args: + user_id: The ID of the user whose tasks to retrieve + + Returns: + JSON response with list of active tasks + """ + try: + task_manager = get_task_manager() + user_tasks = await task_manager.get_user_tasks(user_id) + + # Convert task info to JSON-serializable format + tasks_data = [] + for task_id, task_info in user_tasks.items(): + tasks_data.append({ + 'task_id': task_id, + 'task_type': task_info.task_type, + 'status': task_info.status, + 'created_at': task_info.created_at.isoformat(), + 'metadata': task_info.metadata + }) + + return jsonify({ + 'tasks': tasks_data, + 'count': len(tasks_data) + }), 200 + + except Exception as e: + logger.error(f"Error fetching tasks for user {user_id}: {str(e)}") + return jsonify({ + 'error': 'Internal server error while fetching user tasks' + }), 500 + + +@tasks_bp.route('/status', methods=['GET']) +async def get_task_status(): + """ + Get overall task manager status (for debugging/monitoring). + + Returns: + JSON response with task manager statistics + """ + try: + task_manager = get_task_manager() + active_count = await task_manager.get_active_task_count() + + return jsonify({ + 'active_tasks': active_count, + 'status': 'operational' + }), 200 + + except Exception as e: + logger.error(f"Error fetching task manager status: {str(e)}") + return jsonify({ + 'error': 'Internal server error while fetching status' + }), 500 \ No newline at end of file diff --git a/backend/app/services/__pycache__/ai_persona_service.cpython-313.pyc b/backend/app/services/__pycache__/ai_persona_service.cpython-313.pyc index b377fea4660af24f8e97468081aaefe2c92fb80a..0959269e87f7e00b22695a1845d30e6d3b5c65d5 100644 GIT binary patch delta 5489 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 diff --git a/backend/app/services/ai_persona_service.py b/backend/app/services/ai_persona_service.py index aec3fb4b..650f2ae5 100644 --- a/backend/app/services/ai_persona_service.py +++ b/backend/app/services/ai_persona_service.py @@ -58,11 +58,64 @@ def _sanitize_persona_data_for_json(persona_data: Dict[str, Any]) -> Dict[str, A return sanitized +def _sanitize_json_response(response: str) -> str: + """ + Sanitize JSON response from LLM to handle high-temperature artifacts. + + Args: + response: Raw JSON response string from LLM + + Returns: + Sanitized JSON string safe for parsing + """ + import re + + # Step 1: Remove invalid control characters (but preserve valid whitespace) + sanitized = re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]', '', response) + + # Step 2: Replace smart quotes and similar characters + sanitized = sanitized.replace('"', '"').replace('"', '"') + sanitized = sanitized.replace(''', "'").replace(''', "'") + sanitized = sanitized.replace('…', '...') + + # Step 3: Remove trailing commas + sanitized = re.sub(r',(\s*[}\]])', r'\1', sanitized) + + # Step 4: Try to fix common newline issues in strings + # Replace unescaped newlines within string values + lines = sanitized.split('\n') + fixed_lines = [] + in_string = False + string_char = None + + for line in lines: + if not in_string: + fixed_lines.append(line) + else: + # We're continuing a string from previous line + fixed_lines[-1] += '\\n' + line.strip() + + # Track if we're inside a string + i = 0 + while i < len(line): + char = line[i] + if char in ['"', "'"] and (i == 0 or line[i-1] != '\\'): + if not in_string: + in_string = True + string_char = char + elif char == string_char: + in_string = False + string_char = None + i += 1 + + return '\n'.join(fixed_lines).strip() + + async def generate_basic_personas( audience_brief: str, research_objective: Optional[str] = None, count: int = 5, - temperature: float = 0.8, + temperature: float = 1.0, customer_data_session_id: Optional[str] = None, llm_model: Optional[str] = None ) -> List[Dict[str, Any]]: @@ -124,7 +177,7 @@ async def generate_basic_personas( model_name=llm_model ) - # Try to clean up the response for proper JSON parsing + # Enhanced JSON cleaning for high-temperature responses clean_response = raw_response # Remove markdown code blocks if present @@ -143,6 +196,9 @@ async def generate_basic_personas( if end_idx != -1 and end_idx > start_idx: clean_response = clean_response[start_idx:end_idx+1] + # Sanitize JSON for high-temperature responses + clean_response = _sanitize_json_response(clean_response) + # Parse the JSON manually try: print(f"Attempting to parse JSON array: {clean_response[:100]}...") @@ -153,7 +209,20 @@ async def generate_basic_personas( raise PersonaGenerationError(f"Expected an array of personas but got {type(personas_array)}") except json.JSONDecodeError as e: - raise PersonaGenerationError(f"Failed to parse JSON response: {str(e)}. Raw response: {clean_response[:200]}...") + # Enhanced error logging for high-temperature JSON issues + error_pos = getattr(e, 'pos', 0) + error_context = clean_response[max(0, error_pos-50):error_pos+50] if error_pos > 0 else clean_response[:100] + + print(f"JSON Parse Error at position {error_pos}: {str(e)}") + print(f"Error context: ...{error_context}...") + print(f"Temperature might be too high (>{temperature or 'unknown'}) causing malformed JSON") + + raise PersonaGenerationError( + f"Failed to parse JSON response: {str(e)}. " + f"This often happens with high temperature values (>{temperature or 'unknown'}). " + f"Try lowering the temperature to 1.0 or below for more reliable JSON formatting. " + f"Context: ...{error_context[:100]}..." + ) except LLMServiceError as e: raise PersonaGenerationError(f"Error from LLM service: {str(e)}") @@ -204,7 +273,7 @@ async def generate_basic_personas( async def generate_persona( prompt_customization: Optional[str] = None, basic_persona: Optional[Dict[str, Any]] = None, - temperature: float = 0.7, + temperature: float = 1.0, customer_data_session_id: Optional[str] = None, llm_model: Optional[str] = None ) -> Dict[str, Any]: @@ -313,7 +382,7 @@ async def generate_persona( async def generate_persona_summary( persona_data: Dict[str, Any], - temperature: float = 0.7, + temperature: float = 1.0, llm_model: Optional[str] = None ) -> Dict[str, Any]: """ @@ -418,7 +487,7 @@ async def generate_persona_summary( async def generate_persona_download_summary( persona_data: Dict[str, Any], - temperature: float = 0.7, + temperature: float = 1.0, llm_model: Optional[str] = None ) -> str: """ @@ -575,7 +644,7 @@ LIFE SCENARIOS REQUIREMENTS: async def enhance_audience_brief( audience_brief: str, research_objective: str, - temperature: float = 0.7 + temperature: float = 1.0 ) -> Dict[str, List[str]]: """ Generate suggestions to improve both audience brief and research objective for better persona generation. diff --git a/backend/app/services/task_manager.py b/backend/app/services/task_manager.py new file mode 100644 index 00000000..4a6307b9 --- /dev/null +++ b/backend/app/services/task_manager.py @@ -0,0 +1,228 @@ +""" +Task Manager Service for handling cancellable long-running operations. + +This service provides a centralized way to track and cancel asyncio tasks +across all generation processes in the application. +""" + +import asyncio +import uuid +from typing import Dict, Optional, Any +from datetime import datetime +import logging + +logger = logging.getLogger(__name__) + + +class TaskInfo: + """Information about a running task.""" + + def __init__(self, task_id: str, task: asyncio.Task, task_type: str, user_id: str = None, metadata: Dict[str, Any] = None): + self.task_id = task_id + self.task = task + self.task_type = task_type + self.user_id = user_id + self.metadata = metadata or {} + self.created_at = datetime.utcnow() + self.status = "running" + + +class TaskManager: + """Singleton service for managing cancellable tasks.""" + + _instance = None + _lock = asyncio.Lock() + + def __new__(cls): + if cls._instance is None: + cls._instance = super(TaskManager, cls).__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + if not getattr(self, '_initialized', False): + self._tasks: Dict[str, TaskInfo] = {} + self._task_lock = asyncio.Lock() + self._initialized = True + + def generate_task_id(self) -> str: + """Generate a unique task ID.""" + return str(uuid.uuid4()) + + async def register_task( + self, + task: asyncio.Task, + task_type: str, + user_id: str = None, + metadata: Dict[str, Any] = None, + task_id: str = None + ) -> str: + """ + Register a new task for tracking and potential cancellation. + + Args: + task: The asyncio task to track + task_type: Type of task (e.g., 'persona_generation', 'discussion_guide') + user_id: ID of the user who initiated the task + metadata: Additional metadata about the task + task_id: Optional custom task ID (will generate if not provided) + + Returns: + The task ID for tracking + """ + if task_id is None: + task_id = self.generate_task_id() + + async with self._task_lock: + task_info = TaskInfo(task_id, task, task_type, user_id, metadata) + self._tasks[task_id] = task_info + + # Add callback to clean up when task completes + task.add_done_callback(lambda _: asyncio.create_task(self._cleanup_completed_task(task_id))) + + logger.info(f"Registered task {task_id} of type {task_type} for user {user_id}") + return task_id + + async def cancel_task(self, task_id: str) -> bool: + """ + Cancel a task by its ID. + + Args: + task_id: The ID of the task to cancel + + Returns: + True if task was found and cancelled, False otherwise + """ + async with self._task_lock: + task_info = self._tasks.get(task_id) + if not task_info: + logger.warning(f"Task {task_id} not found for cancellation") + return False + + if task_info.task.done(): + logger.info(f"Task {task_id} already completed") + return False + + # Cancel the task + task_info.task.cancel() + task_info.status = "cancelled" + + logger.info(f"Cancelled task {task_id} of type {task_info.task_type}") + return True + + async def get_task_info(self, task_id: str) -> Optional[TaskInfo]: + """Get information about a task by its ID.""" + async with self._task_lock: + return self._tasks.get(task_id) + + async def get_user_tasks(self, user_id: str) -> Dict[str, TaskInfo]: + """Get all active tasks for a specific user.""" + async with self._task_lock: + return { + task_id: task_info + for task_id, task_info in self._tasks.items() + if task_info.user_id == user_id and not task_info.task.done() + } + + async def _cleanup_completed_task(self, task_id: str): + """Internal method to clean up completed tasks.""" + async with self._task_lock: + task_info = self._tasks.get(task_id) + if task_info: + logger.info(f"Cleaning up completed task {task_id}") + del self._tasks[task_id] + + async def get_active_task_count(self) -> int: + """Get the number of currently active tasks.""" + async with self._task_lock: + return len([t for t in self._tasks.values() if not t.task.done()]) + + async def cleanup_all_tasks(self): + """Force cleanup of all tasks (useful for testing/shutdown).""" + async with self._task_lock: + for task_info in self._tasks.values(): + if not task_info.task.done(): + task_info.task.cancel() + self._tasks.clear() + logger.info("All tasks cleaned up") + + +# Global instance +task_manager = TaskManager() + + +def get_task_manager() -> TaskManager: + """Get the global task manager instance.""" + return task_manager + + +async def register_cancellable_task( + task: asyncio.Task, + task_type: str, + user_id: str = None, + metadata: Dict[str, Any] = None +) -> str: + """ + Convenience function to register a task with the global task manager. + + Returns: + The task ID for tracking + """ + return await get_task_manager().register_task(task, task_type, user_id, metadata) + + +async def cancel_task_by_id(task_id: str) -> bool: + """ + Convenience function to cancel a task by ID. + + Returns: + True if task was found and cancelled, False otherwise + """ + return await get_task_manager().cancel_task(task_id) + + +class CancellableTask: + """ + Context manager for creating cancellable tasks with automatic cleanup. + + Usage: + async with CancellableTask("persona_generation", user_id="123") as task_id: + # Your long-running operation here + await some_async_operation() + """ + + def __init__(self, task_type: str, user_id: str = None, metadata: Dict[str, Any] = None): + self.task_type = task_type + self.user_id = user_id + self.metadata = metadata + self.task_id = None + + async def __aenter__(self): + # Get the current task + current_task = asyncio.current_task() + if current_task: + self.task_id = await register_cancellable_task( + current_task, self.task_type, self.user_id, self.metadata + ) + return self.task_id + + async def __aexit__(self, exc_type, exc_val, exc_tb): + # Cleanup is handled automatically by the task manager + pass + + +def check_cancellation(): + """ + Decorator to add cancellation checkpoints to functions. + Should be used on functions that have long-running loops or operations. + """ + def decorator(func): + async def wrapper(*args, **kwargs): + # Check if current task is cancelled before proceeding + current_task = asyncio.current_task() + if current_task and current_task.cancelled(): + raise asyncio.CancelledError("Task was cancelled") + + return await func(*args, **kwargs) + return wrapper + return decorator \ No newline at end of file diff --git a/backend/app/websocket_manager_async.py b/backend/app/websocket_manager_async.py index 378a6385..154999c8 100644 --- a/backend/app/websocket_manager_async.py +++ b/backend/app/websocket_manager_async.py @@ -31,6 +31,54 @@ class AsyncWebSocketManager: def _register_handlers(self): """Register all WebSocket event handlers.""" + @self.sio.event + async def cancel_task(sid, data): + """Handle task cancellation requests via WebSocket.""" + try: + task_id = data.get('task_id') + if not task_id: + await self.sio.emit('error', {'message': 'Missing task_id'}, to=sid) + return + + # Get user ID from session + session_info = self.user_sessions.get(sid) + if not session_info: + await self.sio.emit('error', {'message': 'Invalid session'}, to=sid) + return + + user_id = session_info.get('user_id') + logger.info(f"WebSocket cancellation request for task {task_id} from user {user_id}") + print(f"🔥 WebSocket: Received cancellation request for task {task_id}") + + # Cancel the task using task manager + from app.services.task_manager import get_task_manager + task_manager = get_task_manager() + success = await task_manager.cancel_task(task_id) + + if success: + # Broadcast cancellation success to user + await self.emit_to_user( + user_id, + 'task_cancelled', + { + 'task_id': task_id, + 'message': 'Task cancelled successfully' + } + ) + print(f"✅ WebSocket: Successfully cancelled task {task_id}") + logger.info(f"Successfully cancelled task {task_id} via WebSocket") + else: + await self.sio.emit('error', { + 'message': 'Task not found or already completed', + 'task_id': task_id + }, to=sid) + print(f"❌ WebSocket: Task {task_id} not found or already completed") + + except Exception as e: + logger.error(f"Error in WebSocket task cancellation: {e}") + print(f"❌ WebSocket: Cancellation error: {e}") + await self.sio.emit('error', {'message': 'Cancellation failed'}, to=sid) + @self.sio.event async def connect(sid, environ, auth): """Handle WebSocket connection.""" @@ -218,6 +266,36 @@ class AsyncWebSocketManager: logger.error(f"Failed to leave focus group room: {e}") return False + async def emit_to_user(self, user_id: str, event: str, data: Any): + """Emit an event to a specific user across all their sessions.""" + try: + user_sessions = [] + + # Find all sessions for this user + for session_id, session_info in self.user_sessions.items(): + if session_info.get('user_id') == user_id: + user_sessions.append(session_id) + + if not user_sessions: + logger.debug(f"No active sessions found for user {user_id}") + return + + # Prepare the event data + event_data = { + 'user_id': user_id, + 'timestamp': datetime.utcnow().isoformat(), + **data + } + + # Send to all user sessions + for session_id in user_sessions: + await self.sio.emit(event, event_data, to=session_id) + + logger.debug(f"Emitted '{event}' to user {user_id} ({len(user_sessions)} sessions)") + + except Exception as e: + logger.error(f"Failed to emit to user {user_id}: {e}") + async def emit_to_focus_group(self, focus_group_id: str, event: str, data: Any, include_sender: bool = True, sender_session_id: Optional[str] = None): """Emit an event to all users in a focus group room.""" process_id = os.getpid() diff --git a/backend/prompts/key-theme-extraction.md b/backend/prompts/key-theme-extraction.md index 9d27f30d..5e358429 100644 --- a/backend/prompts/key-theme-extraction.md +++ b/backend/prompts/key-theme-extraction.md @@ -51,9 +51,10 @@ For each theme: - Copy the quote text VERBATIM - do not paraphrase, summarize, or modify any words - Use the exact message ID and speaker name as they appear in the conversation transcript - Format: "[MSG_ID:message_id] [Speaker Name]: exact quote text" -- If a message is very long, you may extract a relevant portion, but that portion must be word-for-word identical +- **Keep quotes concise**: Extract only 1-2 sentences that best capture the theme. If a message is very long, extract the most relevant 1-2 sentences, but that portion must be word-for-word identical - Do not combine multiple messages into a single quote - Do not add, remove, or change punctuation from the original text +- Avoid extracting entire paragraphs - focus on the most impactful sentences Extract themes that: - Represent significant patterns in the discussion @@ -70,21 +71,21 @@ EXAMPLE_JSON_START "title": "Price Sensitivity", "description": "Participants consistently mentioned cost as a primary factor in their purchasing decisions. Several noted that they would pay more for quality but have clear price thresholds.", "quotes": [ - "[MSG_ID:abc123] [Sarah]: I always check the price first - if it's over $50, I won't even consider it", - "[MSG_ID:def456] [Michael]: Quality matters, but I have a hard limit of $100 for this type of product", - "[MSG_ID:ghi789] [Jennifer]: The price point really determines whether I'll buy it or not" + "[MSG_ID:abc123] [Sarah]: I always check the price first - if it's over $50, I won't even consider it.", + "[MSG_ID:def456] [Michael]: I have a hard limit of $100 for this type of product.", + "[MSG_ID:ghi789] [Jennifer]: The price point really determines whether I'll buy it or not." ] }, { "title": "Brand Loyalty Drivers", "description": "Customer service experiences strongly influence brand loyalty across all age groups. Negative experiences were cited as the main reason for switching brands.", "quotes": [ - "[MSG_ID:jkl012] [David]: After that terrible customer service experience, I switched to their competitor", - "[MSG_ID:mno345] [Lisa]: I've been loyal to this brand for years because they always treat me well", - "[MSG_ID:pqr678] [Robert]: Good customer service is what keeps me coming back" + "[MSG_ID:jkl012] [David]: After that terrible customer service experience, I switched to their competitor.", + "[MSG_ID:mno345] [Lisa]: I've been loyal to this brand for years because they always treat me well.", + "[MSG_ID:pqr678] [Robert]: Good customer service is what keeps me coming back." ] } ] EXAMPLE_JSON_END -Analyse the entire discussion and extract the most insightful themes. \ No newline at end of file +Analyse the entire discussion and extract the most insightful themes. **Remember: Keep quotes short and punchy - extract only 1-2 sentences that best capture each theme, not entire paragraphs.** \ No newline at end of file diff --git a/backend/prompts/persona-basic-generation.md b/backend/prompts/persona-basic-generation.md index 0a30d543..c2da0fba 100644 --- a/backend/prompts/persona-basic-generation.md +++ b/backend/prompts/persona-basic-generation.md @@ -20,6 +20,7 @@ For each persona, provide these basic demographic and personality details: - Avoid stereotypes while still making personas feel authentic and relatable - Ensure demographic attributes are believable and represented across varied ages, genders, ethnicities and social grades - Ensure personalities are distinct enough to elicit varied reactions in subsequent studies. +- **CRITICAL PERSONALITY DIVERSITY**: Generate personas with widely varied OCEAN personality traits across the full 0-100 spectrum. Avoid clustering around median scores (40-60). Some personas should have extreme trait combinations (e.g., very low agreeableness 0-25, high neuroticism 75-100, very low extraversion 0-25). Include challenging archetypes with difficult personality combinations - these extreme but psychologically possible profiles are valuable for research diversity. - Obey Market Research Society guidelines Example of the exact JSON format to return: @@ -59,6 +60,8 @@ IMPORTANT: - Do not include any text before or after the JSON array - Do not wrap the response in markdown code blocks - Return the raw JSON array only +- CRITICAL: Ensure all JSON strings are properly escaped - no unescaped quotes, newlines, or control characters within string values +- All string values must be valid JSON strings with proper escaping (use \" for quotes, \\n for newlines, etc.) - Ensure diversity among the personas (different ages, genders, backgrounds, etc.) - Make each persona relevant to both the audience brief AND research objective provided - If no research objective is provided, focus solely on the audience brief \ No newline at end of file diff --git a/backend/prompts/persona-detailed-generation.md b/backend/prompts/persona-detailed-generation.md index 184c38fc..88553022 100644 --- a/backend/prompts/persona-detailed-generation.md +++ b/backend/prompts/persona-detailed-generation.md @@ -23,6 +23,7 @@ Generate all required fields and populate optional fields where appropriate. Ens - Demographic details are realistic and specific - Personality traits form a coherent whole - Goals, frustrations, and motivations are interconnected and, if a research objective is provided, should be specifically relevant to that research focus +- **OCEAN TRAIT DIVERSITY**: Use the full 0-100 spectrum for OCEAN personality traits. Avoid clustering around median scores (40-60). Generate some personas with extreme trait combinations that are psychologically possible but challenging (e.g., very low agreeableness 0-25, high neuroticism 75-100, very low extraversion 0-25). These diverse personality archetypes provide valuable research insights and should be represented alongside more typical profiles. - OCEAN trait scores align with described personality - ThinkFeelDo entries reflect authentic human patterns - Scenarios are medium-length, detailed life situations that logically flow from the persona's characteristics. Each scenario should be several sentences or one short paragraph long, providing sufficient context and detail. CRITICAL: When a research objective is provided, at least 3 out of 5 scenarios MUST specifically show this persona's real-world interactions with the research topic. These scenarios must be concrete, realistic situations that demonstrate how the research subject matter appears in their daily life, influences their decisions, creates problems they need to solve, or impacts their experiences. The remaining scenarios can show general life contexts but must still reflect how the research topic might indirectly influence their overall lifestyle and choices diff --git a/backend/prompts/persona-system.md b/backend/prompts/persona-system.md index 2e0412d8..7680ab99 100644 --- a/backend/prompts/persona-system.md +++ b/backend/prompts/persona-system.md @@ -17,6 +17,8 @@ You apply Internal Consistency Checks, ensuring demographic, psychographic, tech You explicitly define Core Goals relevant to the product/service category outlined in the Audience Brief and map persona characteristics to Journey Contexts where applicable. +You prioritize psychological diversity in personality trait generation. When creating OCEAN personality profiles, you utilize the full 0-100 spectrum rather than clustering around median scores. You understand that extreme but psychologically valid trait combinations (such as very low agreeableness, high neuroticism, or very low extraversion) provide valuable research insights. These challenging personality archetypes, while potentially difficult, represent real human psychological diversity and enhance the authenticity and utility of research outcomes. + You have an ethical framework that ensures demographic balance. However you always measure this against the requirements of the audience brief (e.g. you ensure you are generating personas that adequately reflect the needs of the brief). Your ethical framework includes: - Socioeconomic diversity diff --git a/dist/index.html b/dist/index.html index e505d8ce..d233cfce 100644 --- a/dist/index.html +++ b/dist/index.html @@ -7,8 +7,8 @@ - - + + diff --git a/src/components/AIRecruiter.tsx b/src/components/AIRecruiter.tsx index 722e59a9..0a8028a7 100644 --- a/src/components/AIRecruiter.tsx +++ b/src/components/AIRecruiter.tsx @@ -4,12 +4,14 @@ import { z } from "zod"; import { Users } from 'lucide-react'; import { toast } from 'sonner'; import { useLocation, useNavigate } from 'react-router-dom'; -import { Progress } from "@/components/ui/progress"; import AIRecruiterForm, { formSchema } from './ai-recruiter/AIRecruiterForm'; import PersonaReviewList from './ai-recruiter/PersonaReviewList'; import { generateSyntheticPersonas } from '@/utils/personaGenerator'; import { usePersonaStorage, GENERATED_PERSONAS_KEY } from '@/hooks/usePersonaStorage'; +import { useCancellableGeneration } from '@/hooks/useCancellableGeneration'; +import { getSocket } from '@/services/websocketServiceNew'; +import GenerationProgressBar from '@/components/ui/GenerationProgressBar'; import { Persona } from "@/types/persona"; interface AIRecruiterProps { @@ -22,11 +24,14 @@ export default function AIRecruiter({ targetFolderId, targetFolderName }: AIRecr const navigate = useNavigate(); const { loadPersonas, savePersonas } = usePersonaStorage(); - const [isGenerating, setIsGenerating] = useState(false); + // Get WebSocket instance from singleton service + const socket = getSocket(); + + const [generationState, generationControls] = useCancellableGeneration('persona generation', socket); const [generatedPersonas, setGeneratedPersonas] = useState([]); const [selectedPersonas, setSelectedPersonas] = useState([]); const [showReview, setShowReview] = useState(false); - const [generationProgress, setGenerationProgress] = useState(0); + const [generationToastId, setGenerationToastId] = useState(null); // Check URL params for state restoration useEffect(() => { @@ -45,11 +50,19 @@ export default function AIRecruiter({ targetFolderId, targetFolderName }: AIRecr } } }, [location, loadPersonas]); + + // Dismiss generation toast when cancellation completes + useEffect(() => { + if (!generationState.isGenerating && !generationState.isCancelling && generationToastId) { + toast.dismiss(generationToastId); + setGenerationToastId(null); + } + }, [generationState.isGenerating, generationState.isCancelling, generationToastId]); async function onSubmit(values: z.infer) { try { - setIsGenerating(true); - setGenerationProgress(0); + // Start generation without task ID - will be set when real task ID arrives via WebSocket + generationControls.startGeneration(); // Validate count before proceeding const count = parseInt(values.personaCount); @@ -57,42 +70,22 @@ export default function AIRecruiter({ targetFolderId, targetFolderName }: AIRecr toast.error("Invalid number of personas", { description: "Please enter a number between 1 and 10" }); - setIsGenerating(false); + generationControls.resetGeneration(); return; } - // Start progress animation - setGenerationProgress(5); // Initial progress - - // Simulate progress while waiting for personas to generate - const progressInterval = setInterval(() => { - setGenerationProgress(prev => { - // Increase gradually but never reach 100% until actually complete - if (prev < 90) { - return prev + Math.random() * 5; - } - return prev; - }); - }, 500); - // Adjust the expected time based on the count const estimatedTime = count <= 2 ? "30-60 seconds" : count <= 4 ? "1-2 minutes" : count <= 6 ? "2-3 minutes" : "3-5 minutes"; - // Warn about potential timeouts for larger counts - if (count > 4) { - toast.info("Generation may take longer", { - description: `Generating ${count} personas at once may result in some timeouts. If this happens, the successfully created personas will still be saved.`, - duration: 8000 - }); - } - toast.info("Generating AI personas", { + const toastId = toast.info("Generating AI personas", { description: `Creating ${count} synthetic personas based on your brief. This may take ${estimatedTime}. Please be patient.`, duration: 10000 }); + setGenerationToastId(toastId); // Show folder info in toast if available if (targetFolderId && targetFolderName) { @@ -113,22 +106,55 @@ export default function AIRecruiter({ targetFolderId, targetFolderName }: AIRecr count, values.dataFile, targetFolderId, - values.llm_model - ); + values.llm_model, + // Pass a callback to set task ID as soon as we get it + (taskId: string) => { + console.log('🔥 Early task ID callback:', taskId); + generationControls.setTaskId(taskId); + }, + values.temperature + ).catch(error => { + // Check if generation was cancelled early (before first API response) + if (generationState.taskId?.startsWith('temp-') && !generationState.isGenerating) { + console.log('🔥 Generation was cancelled early - not propagating error'); + return null; // Don't propagate the error if cancelled + } + throw error; // Re-throw other errors + }); + + // Handle early cancellation + if (!response) { + console.log('🔥 Generation was cancelled - no response to process'); + return; // Exit early if cancelled + } + + // Check if response includes task ID and set it + console.log('🔥 Full response:', response); + console.log('🔥 Response task_id:', response.task_id); + if (response.task_id) { + console.log('🔥 Setting task ID:', response.task_id); + generationControls.setTaskId(response.task_id); + } else { + console.log('🔥 No task_id in response!'); + } // Extract personas from the response const personas = response.personas || response; - // Clear the progress interval - clearInterval(progressInterval); - // Set progress to 100% when done - setGenerationProgress(100); - // Check for partial success (some personas generated, some failed) if (personas && personas.length > 0) { // Log successful generation with model info console.log(`✅ Successfully generated ${personas.length} personas using model: ${values.llm_model || 'gemini-2.5-pro'}`); + // Mark generation as complete + generationControls.completeGeneration(); + + // Dismiss the generation toast + if (generationToastId) { + toast.dismiss(generationToastId); + setGenerationToastId(null); + } + // Check if we got a response with partial success info if (response.partial_success || (response.errors && response.errors.length > 0)) { // Some personas succeeded but others failed @@ -153,12 +179,20 @@ export default function AIRecruiter({ targetFolderId, targetFolderName }: AIRecr }); } - // Navigate directly back to synthetic users list - navigate('/synthetic-users?mode=view'); + // Wait a moment before navigating to show completion + setTimeout(() => { + navigate('/synthetic-users?mode=view'); + }, 1500); } else { throw new Error("No personas were generated"); } } catch (error) { + // Check if this was a cancellation (expected behavior) + if (error.response?.status === 499) { + // Task was cancelled - this is handled by the WebSocket cancellation system + return; + } + console.error(`❌ Error generating personas using model: ${values.llm_model || 'gemini-2.5-pro'}:`, error); let errorMessage = "Please try again or adjust your parameters"; @@ -189,16 +223,19 @@ export default function AIRecruiter({ targetFolderId, targetFolderName }: AIRecr errorMessage = error.message; } + // Mark generation as failed + generationControls.failGeneration(errorMessage); + + // Dismiss the generation toast + if (generationToastId) { + toast.dismiss(generationToastId); + setGenerationToastId(null); + } + toast.error(errorTitle, { description: errorMessage, duration: 6000 // Show for longer to ensure user sees it }); - } finally { - // Wait a moment to show 100% completion before resetting - setTimeout(() => { - setIsGenerating(false); - setGenerationProgress(0); - }, 500); } } @@ -317,26 +354,36 @@ export default function AIRecruiter({ targetFolderId, targetFolderName }: AIRecr

AI Persona Recruiter

- {isGenerating && ( + {(generationState.isGenerating || generationState.isCancelling) && (
-
- Generating personas... - {Math.round(generationProgress)}% -
- + { + console.log('🔥 Progress bar completed - resetting state'); + // This should trigger when progress bar finishes hiding + }} + />
)} {!showReview ? ( ) : ( (null); const [draftFocusGroupId, setDraftFocusGroupId] = useState(null); @@ -938,10 +942,8 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved, preSele // Function to generate a discussion guide via the API const generateDiscussionGuide = async (values: z.infer, focusGroupId?: string): Promise => { - // Reset states - setIsGenerating(true); - setGuideGenerationComplete(false); - setGuideGenerationError(false); + // Start cancellable generation + guideGenerationControls.startGeneration(); try { // Prepare data for API request @@ -961,16 +963,26 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved, preSele ? await focusGroupsApi.generateDiscussionGuideForGroup(focusGroupId, requestData) : await focusGroupsApi.generateDiscussionGuide(requestData); + // Set task ID if available + if (response.data?.task_id) { + guideGenerationControls.setTaskId(response.data.task_id); + } + // Check if we got a successful response with a discussion guide if (response.data && response.data.discussionGuide) { - setGuideGenerationComplete(true); + guideGenerationControls.completeGeneration(); return response.data.discussionGuide; } else { throw new Error("Failed to generate discussion guide"); } } catch (error) { + // Check if this was a cancellation + if (error.response?.status === 499) { + return ''; + } + console.error("Error generating discussion guide:", error); - setGuideGenerationError(true); + guideGenerationControls.failGeneration(error.message || 'Failed to generate discussion guide'); // Extract error message from axios error response let errorMessage = 'Unknown error occurred'; @@ -1005,11 +1017,18 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved, preSele }; const handleGuideProgressComplete = () => { - setIsGenerating(false); - setGuideGenerationComplete(false); - setGuideGenerationError(false); + guideGenerationControls.resetGeneration(); }; + // Switch to Setup tab when discussion guide generation is cancelled + useEffect(() => { + if (!guideGenerationState.isGenerating && !guideGenerationState.isCancelling && + guideGenerationState.taskId === null && activeTab === 'review') { + // This indicates cancellation completed - switch back to setup + setActiveTab('setup'); + } + }, [guideGenerationState.isGenerating, guideGenerationState.isCancelling, guideGenerationState.taskId, activeTab]); + async function onSubmit(values: z.infer) { try { // Use existing focus group ID or create new draft for discussion guide generation @@ -1069,6 +1088,13 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved, preSele try { // Generate discussion guide based on form input (after database is updated) const guide = await generateDiscussionGuide(values, focusGroupId); + + // Check if generation was cancelled (returns empty string) + if (!guide || guide.trim() === '') { + console.log('Discussion guide generation was cancelled'); + return; // Exit early, don't process or show success toasts + } + setDiscussionGuide(guide); // Update the focus group with the discussion guide @@ -1426,14 +1452,18 @@ true; {/* Progress Bar - Consistent top placement for discussion guide generation */} - {isGenerating && ( + {guideGenerationState.isGenerating && (
)} @@ -1677,7 +1707,7 @@ Controls how much time GPT-5 spends thinking before responding setIsCopyGuideModalOpen(true); fetchAvailableFocusGroups(); }} - disabled={isGenerating} + disabled={guideGenerationState.isGenerating} className="min-w-32" > @@ -1691,11 +1721,11 @@ Controls how much time GPT-5 spends thinking before responding const formData = form.getValues(); form.handleSubmit(onSubmit)(e); }} - disabled={isGenerating} + disabled={guideGenerationState.isGenerating} className="min-w-32" > - {isGenerating ? "Generating..." : "Generate Discussion Guide"} + {guideGenerationState.isGenerating ? "Generating..." : "Generate Discussion Guide"} @@ -1732,7 +1762,7 @@ Controls how much time GPT-5 spends thinking before responding /> ) : (
- {guideGenerationError ? ( + {guideGenerationState.hasError ? (

Discussion guide generation failed.

Go back to the Setup tab and try generating again. Check your inputs and try a different AI model if the issue persists.

diff --git a/src/components/ai-recruiter/AIRecruiterForm.tsx b/src/components/ai-recruiter/AIRecruiterForm.tsx index 66ceb4fd..d533b73b 100644 --- a/src/components/ai-recruiter/AIRecruiterForm.tsx +++ b/src/components/ai-recruiter/AIRecruiterForm.tsx @@ -36,6 +36,7 @@ export const formSchema = z.object({ personaCount: z.string().min(1, { message: "Number of personas is required.", }), + temperature: z.number().min(0).max(1.5).optional(), dataFile: z.instanceof(FileList).optional(), llm_model: z.string().optional(), }); @@ -68,6 +69,7 @@ export default function AIRecruiterForm({ onSubmit, isGenerating }: AIRecruiterF audienceBrief: "", researchObjective: "", personaCount: "5", + temperature: 1.0, llm_model: "gemini-2.5-pro", }, }); @@ -410,6 +412,41 @@ export default function AIRecruiterForm({ onSubmit, isGenerating }: AIRecruiterF />
+ {/* Temperature Control - Full Width */} + ( + + AI Creativity Level (Temperature) + +
+ field.onChange(parseFloat(e.target.value))} + className="w-full" + /> +
+ Conservative (0.0) +
+ Current: {field.value?.toFixed(1) || '1.0'} +
+ Creative (1.5) +
+
+
+ + Lower values (0.0-0.5) make the AI follow prompts and documents more closely. Higher values (1.0-1.5) give the AI more freedom to increase variety in persona names, backgrounds, and traits. Range: 0.0 to 1.5 for optimal reliability. + + +
+ )} + /> +