From 3260b99c6b5cffbf159919ab2342126b5f636941 Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Mon, 24 Nov 2025 23:10:06 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=BA=86=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=80=BB=E7=BB=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .DS_Store | Bin 8196 -> 10244 bytes .dockerignore | 51 ++ Dockerfile | 17 +- app.zip | Bin 64935 -> 68653 bytes app/api/endpoints/meetings.py | 446 ++++++++++++++---- app/core/config.py | 6 + app/services/async_meeting_service.py | 90 +++- test/test_upload_audio_complete.py | 275 +++++++++++ .../test_voiceprint_api.py | 0 9 files changed, 796 insertions(+), 89 deletions(-) create mode 100644 .dockerignore create mode 100644 test/test_upload_audio_complete.py rename test_voiceprint_api.py => test/test_voiceprint_api.py (100%) diff --git a/.DS_Store b/.DS_Store index 408a8dd5e3f31b0516c8c8cfb9de5f7e83df1c26..acab3771c171a88e9802cabad656a375c25b6a95 100644 GIT binary patch delta 240 zcmZp1XbF&DU|?W$DortDU{C-uIe-{M3-C-V6q~50$SAilU^hRb++-esDAq8b#H!8N z0tw7vvJ52*sSL$HGM=H7p@1QWA)g_UAqB`w1oBfDGJ*VfhGZa_3zkgl_mRV+}P= delta 121 zcmZn(XmOBWU|?W$DortDU;r^WfEYvza8E20o2aMAD7!IWH$S87WFCPi);OTV^3B-- z3Cx>SMK~EbOe`&R6pSp*H=BuSGYc{UWr09~8%VfXi ze+o!|0{_4AeUNnjlmGAb0U%eXu>Z6V0J%d4{u{%B{k<~(_T>dbfrvpzY5Sk@Uw!?P z?}K6bpZtHu(&!7T1NHB~fPi=({eL?7=kh`U1H%9#1v7PVVKH}hv;6zvWMym3?Bc7b zfeZ$_`FE`G^#46xPai}uaHxB5Ffb^rzb)gn_0{p$aDpz4=Y|BnsV4=O&1vj|7l2&) znSx!d>?%6&ZJMDwUS{Ldc~j{*(VZo72W1I83kN9yKPz&dgiI*1qr}XfYfj)-)h4n` zcn)55a?8qNb@RiYWh4IqN?&HG-JD>y;f3Pf`c<76`CS1jCCUsqBW|c2W26^^^v7{K zp`t*>@xw%WMCC<0a00JzxcPmQ-Bu5cPqGPY@L80dOaiHey4Sa;4wO6B{05$4Ab%q6 zl4mGDdI$>h0N1ts%~J#spHzcy*oAuX6mHA?Svru2O_m-z(RHB;-haoNKU!q=9MKy} z$yFicKplx8Yj!;4Fjj3pOhYe8=zK-{;L7K-r^P2nfhzl-+Xbh|FeO zSWJ1(JS&P*&LgfBzRM)$I(DkvLzz3UNahCWK*$&~`1yO28NYH9u9%GUwA`1l$M?uI z_6crU*2TY)J$ggoI%qMxe||kbw-4AcE=b=pC9<G#xbqeHJG={^n6AKsUN??YRaR zB-DvJGnQ_9uLr^|!43Qx5$J<2e5P*dw9LV=^{SXS!>7LdBW`;8dBdSm?;B5AQ1%d) z35T~a2d!<0QnRX*m;{=t8X*l=k^mLlf&u+Ij zBYxlA2Y%2bnUjhAj0dl%l-ay54GIQma#~5%dH>df+qubSa^cilgsCC-4SgU7X9Nb6 zB*gK6ra6u7!mTH`^G>iYRwmV5h4c2v;wBa+r52{e(Ayb5t9G2!(M6F8RMEI>pSvf1 zMJWZrMU46nwtkh0&92n?J#C8pToBT!n_tnAzgHVw)xz`2J zzeT!^1AGR36XCqw9CY-W9g+=#Y6s3wCD<9GDD_I)uGcc3P}AUBXfQr8%|WFn4C%~ie!H+>l+0;=&#!|{@~!j`>A||SN-j5^aDlE%9herwlR+MN+YF4 zdfpOoo=vLk3jzkS_mQ(le8|hkOO^X0?xfTQ(yB9(A(toP4ON%D&gG1}&0)2!cThWj zAfY^W_kt?36o^i5hga(x^tVD^bF4l|cucXBJ=-pZ$@;xUq#z*6s&fwT>c5<8TzymR zmoA)kc5cRr6++z=oZA}B@C^R`2bMfAP@F5PVZ@>7ZCHnK-n)pKoyW?iN^`q!XQ=|Z z`e~e2cq=UIM9mkO8)cgK&E*Wd`Uy&6AhntM<_|ay3SR)3^(+zGfG>E-ozG9Ky>^Mv zaJl(%I{G=IMHFHaa?Cye7A3KOhMtp;^<=Hv$9R3~^>(3Us)AVPGdX2Bo?Diggj)E; zpGvqR7ktmQ0gF|~IFa>l&3NK#Q=_%0OM8~j0pMn<)i-J_4<0kKLwna;_Uh{$WlRTO znY~D9rG20G>0yFfmS{u|p~lq47&vfEyMsRcvQ5|pLK}&Nl5nyB!`L)&w_}vt*i~BK zBWmuho9Uc(-bGbL)r>DfEFr|_T%(_WG&JE#<}0h!c{~o^2$Ep>pP%{o8g#qM!(nMoM|&xwV_8JpO_0)>9%y`xT5U{Yah$^? z!gQEolBxPhYMP0*^0IiGpuCBm(k)wV+!~ z_SX+JXrum3sJ#7B-g8x@=k}<32wiTQ2#RVIt;3mM7M5<{>!%4Ab$X%|#q~t{SY?kN zKNgJ~LlDgcGd4pf*3|Ex+BJ--_t8oE`vXVIR2E_dT9K7hug=O1R$+vyP+rXfdxed^ zY8!DqOTGSN){E$>|E`tLoap~E!e~ZL(rB#cb(^HOQG?n1t=E{R+OZ#+v33$09rzWd z;p=1(%k#${(7})FL);g>EwdBk*;q;0$T9FKA(|Esk~=^vPLfkjNkoH4#mxzWzXmht z4i+_&`Tpq^x29k3U4~y6wlZ0tt1tx9^1R%@D4m;lK<@-QG;V8}fyFzuJN~=dIB+|D z(a@nDx{)1ER{KfRT$~4VWAy1Wh780j4@&!>Dz@e38)vnW3hc(4dFNTIW@C3fR}`U%FD%lj>U9XnjQSSefQt}7IDz(jL7OyzjV}s7ZM_BjksO)oj0jf zjtI%XMT%A+UA!yxceutHJjA3=3m3P9YWD#J)B(mjRz~ExU&3e&Eo}(}=tr(EaJ0`v z5P>UgmW^f~kSg-9a5X1@;KX08@sB`Ik%#=d<-`5G|F?7~`YQs6K=nAEKsv`Li2o{u z|0oVn9Tpp8$}L6XZ>%S9*uR18f4?vQ)rC+tnt#d%|5qC|vfwSl{HskGqeuz=|EkHh z!3P9SL5uj$0%`rX{H^~>>HL$|rC|S`{C~a|(>>w;i5q_rFfb}GQc%q~15o)_#S}A8 z+Wu4jA0;zIVDFA8ebWgHrP0Y@p!ZW!dLHtXj>D24@|ml*xR*1Gl;{<@#MV9I>T3`4=|--nGY$ z&VaMCc=q9BYRSn#;=S9NL55`dr;;}eKaT<~V9t-HUUj`qt21aOC$sj=rw{)}Jvha> z*Y{HUmGs2%j=8`t=|7D*0-yCpv6~maR^`u6?MpWKHdhmUEn5qyF_9T6%xVA#?6hp< zKe_4BQ4t{YfU3A31Ox5k_B9%w{T&$bdJPL7Q9_Z1y;Rs&45beg5= z`vF2}lh{~KMJ1ZB!y>OTGbXz(!1i1oOX2*ii1+r+uaLYwalyC1=)Je@?qvp4q;GOU zWTz;pMKMV-xiVljin3;f8`m9bJk`|Lg4CzxdhDiH7K+L^8e)F^ezsx9!{U*v=s=i9 zFqi=~=w9jaT+k}fS>DRRuoNtju2ax(tAuIxhgjFkA9; zW!u51+-XPu_Tg5~w?9DWI9O#t4WKQdbf=?IRk;gLTEG^hwB;37`>f=|tQAo29Qg(& zRm^N#rlOI5=0-r!3k3)Hw4H|3Cy@rM7=CQosfgy$s*>sm>}f)#b{gYq;M9v6G>k-8KsOvMrmsqL z*Bm?uF}R~!ko^3EaJy}zH?#pL%3QZG&y7iPGNV`M2VNnMI9LSBS&T$0KiRM37f5q^ zPqX}3%MneS>GP7BneGFd$qQ=qEr0*b*74{367%pUp~7Z_YHm85yzo+G9>oTjlVC5K zN?IUpMH2qD38D3X5F8ZQE@omE~drRk1lLn7Z^sjw?PmPQRNxV=LjS9X1+lc8RZwb35(w6 z5{bx~*Nzz%k!yzKiHkGNpod@*2^D7YK+s;d%=R~{aR-r#_tnywr@;wCO1 zgl(cIcUD}3`^$qL;QEx|oV$nfD0lu1-DJg-$`Iv{LNS=m`qoYq9^&!K42q|R&mx^M zSSL6pqc*AT_sCI|hPF96UKPq&Bz@Ii^$a~~{L1Fi+{ug<+yW$7S#}YCMFyOoYk5T zWD6|P79ktleS7-RiJ~0uXN?eky>FRlouip?I@vxoVdBGEDr6Q+{8W>aQk7S~ zIe$TV?PS391Ca6_y^$HP1(jx9e4PmJ^8L6($&tEy9R1?vjED;E-v@)iO{d``OH@X! z6H$jL;Z!HHD;MWOh1xt2&xAh{EJCUjL5HY?x@_G;lYN3B6MTGRNC;w_!z|U?{(~2Q-oXhHuSoy~zHt1y{H-Gomcg^%_w*xw`0_%eGZ zd1uqnInwv*P`@K%tPMeKE|0b%Gv%K3%v5p@Y!}T2wq6;{MGmdMJO6Xwcn!Q(;|*FN z1*iL57r?KqOH_>I<$=hXEE?wE3`QhR*@s4Tg~?kRiRT1&-w#;_-Cc((+8#~C`X)os3P_g*}0&np|^O#q{$0pmiI&vZyk`4NC3cCJsj?>kRY7t;(413kV7w)3v`hYrIZH^(_;r=L`ZOSN8QwSRysKRs`gv zqbh&N{qKp}X^jhgApP+t-MX^&+Z)@_eKdr!M$ocSR*e zZiLC>YI@k*L_kpyiOC=Oq%M&6I#0iI@PfZCfKbK$%8@@X7*21&5<5j z!HQV!pzV#6DiK9Tto&OAGhmN`kSe(@3cfU zWazMJd?EK)j+kUrcOe0h`qa2B6U0y_W`XKWOe*QKeQgN+z$X!PRN%@M8R`RP!WZ#& zmnLI*2tx_dSsB?dVWu(eKD%+Sn$@SZhbn%LfZDN9MM&}g2O0&x# zy|}H3>@x+$Pgo&}7|D%eBg-m5n$Gil5=p;Hd!5zKQEj6nN<|*Ui;C#gr{fuEwp@^U zZUR1nq6O7T*Jy$!fgDq)Md^sOhO4I54z@!=4VW}x0-~5a)p*dmRiq2l?Qr+w1WHR{ z-oVK0#W&nQoQKLo;aG&WQD$aYi`qUP=hv&R#fN=bVv}a0MXIOagz}$|+%+Ix_P2J_ z$_$|}AO!ekfjB0L({US|;gkM8<2NcHv_oawhZyMxRKjRfKn8f{SI%)Rv&l^1X4|hy zX2h4vaG|8Hzt$U^;m&rLwBi%4#vKDaT}hb)oY9wmmUZx|y5;XI2id)k>d2Fmr_qqJxH$UlAPT=2}mFD=w z|7)x3N3?$&Akj!W%qVhcR=20|(umTAKRK65ISJ1JdYI=$7kDw5wP3L}b0hXQbWj*3 zGUMYHii|Sf5S6%)F815^?RG2e=nqo`rG$SZht_xs-Ns%ldQ=RfIn<@+D*o6)ipve>Z zqZ|jzv?jW%$Q#>}`goH%1g51D&tZ^~n9@3wD!yG8Kc8)6PmzpdmK*h+S^UuI{axGa znrH(LMEUKP_LD+UeZSrJy3a3w9a&4_r#Ux<2Rib-My57Jf6GWu%7o?s1TSQJ@-SVCmS!{oeBb? zy7~&*tGy~0x3wy}sBrUa6TOyFxzP)36-gI4Jqa zGbuz+{}bW)OGxgr85U9)9_5)j4Dw?e8Tog0ZQ!xmer4H38r#z73Wk{2H1ts_o;rwy z*mDxHeT@Gwj3ljuJGsMA+5n`zW}o40LVH7t zo_K8pa(dwUqIA>h(#&IN*f8$~U}7^e)wt+qhTRzCPxBM$;6PW%Dm;KM4Dhq|mFQJG zK<19@6Z|D|UelN-`f08E29uU}WZ#3`{oaUM*|G-=z1(|&#h*OXV3F^_T~9;CRM^0tcb9!6FWG{uc%4O6@faC-5^E<_vQ3SskMuGGi}6mnw5ao$ri zcF!GnG-nK*>otpL6ArPvCBONa?v$c}O6Wo8@xXvW}3B~mV$x|3|}GwXgkAex_)M6Q4y0c64}n_IO|vdyi0VsRz>UzUn&NO zIW3sV!q-Ffv=;?D?7C;$xw7PjV5BVk59l7hi}52KEp2_E@h*Qpmw)sJax^Ap=IRO` z?diZ@T?BC4PH}I)WQ*1=Rsi3t7f=1_l}mqoSZX4Fv_{8tVD|)QNvAp9r%u{Q(%Eq} z+#{%UX?^6j{Jj0VnQ^_2vh2`pXR{xdynyH>g~xS3w_4@667UU`h6Yt}HOsK|&!14X zYTZheYBE6X@^j@GMq-wusRx3rj$ejS=2%8Wz}YkZwbWT|pJa0F1~@7SRY$y5;-D&_C<)%Sy%a z6#E^#Eqm&nFioki1(QC938%@IP+>wE+3tdWEBg^MW~Vrv6qZ1oaUJ?qN@*< zKRf--f)7_p#drZGWH{=uv?e4Y)(IG^veYWF2y=1z(}yIo2yQZ*W?}^LWL!*`E6{*8iYp`9ajv6JLz0*AT4D|%v!5)*whe%bS{I3{e^mpFWGZe;msYHr5v(Y zQ~al@MTG>+mj@&lFm(D;sysj4*f!(Y)~J*pNNmKq4?EI@ZjbFd!7vu!Yt?Gp!PYRV z`>VV_Fdlh4wuP?9VA*qDV)=31kLNvTiOpEG&~;lEbtx>1EKT){B)pWvU6Z|Ngu?!5 z@9O1qz0GOADT7N2PkC+=i|;H?5Oim<*4M`V>b4=}kD)`xL2?_l!^0ehYfOiW>J=lK z`lvbg3xqsT?eH+7R7wvjJI2=WC-oY9mHL`MTqlku8We%q)|V9Mkf<@*!M<7+Gp9_V>UZZCh$jSq~N!F4BBxqFi;g)5nPdMF_$h!B}ZK%l!FM23Tsm2 zRH79moV)|PL$_ASfZMm&xwr59XN=V#CfLL3d;G4hr{WUE3Ddfo`c2V@=eLN5VeW+hi1@bi+_CpIiM#_4(3w?|+uF$VhKLjHg z4?(8HV!d?0t7hZd`cl_bzo09-jUA?ZwEOTN{|3*z4Lk+>CLR=iElCH>%5O5vqVeGh zDO&p;Ftu?J`vwUG753n(2$G*-h%!}SC%TX}vPcASn>+}A7QZ>lHH$4Td2OPagYSi0xow4nE`ozr&h`l0z}PVBEHmDk#$0zO93+iNNjP8EurX!+};sp6XF_9(2K}!7CA8ag@gZ*0tC12>KZu-e&jGj^Q6cNfJ znH{W8CzdLS72Atbtg2-Fq2+g-hxtv!Mjq)ke4j2LPsM{_<|-OJR(x-q6U*B|I%Z4M zFyP#nbvHz$Qv#tCSHT&MaIJ!w(xLLF_-8gJ7^+vib2u2NHiJjaw%YxHg@{`2*g#&J#X~L1 zN^-=UucF*3(!-OSTCU4|m+X*_AQnz>4VHi|%-0FgKK3z7*%vNcN`xNLN>R0qL*>nN zT3`wqnrFt?x=3KUUJ$egFeb$wHaJ+p^b^*sZ(YsJF^x=132LX+mR+5zc@uk_1&xzX zFh@qWBBMfhHbJ;r-=j?R-1olwyZP>_K;RVhD3>`<*~pY$=1!^|qXzki;eXymi&ZV4 zdieQ#Vz#p)H)(S8;Y|`Pd0CrD|Bte;c0n_6BkNEkz5%_MT3ZA2K`0( zF7^@aus8NGr>d!<^-AaItwZzr@40s~Ohn1Cex!%J#&lZOC>?{J$$7*qNaBXbLr##n z(U!0}s|IhKpDCUJxJY^nRftN}3=2P{7<@!&h#FYn-pIlGoTFax_0yw=UDKVQ+(;vt z9cGtmc}~6c?N{FNu=L`_7AbDI5OQRVe82;9T;kKWzfztB`HGtzQr*PQ-bFFC$VxBq zNoK*}D+O-rQ9uUAZ|Vxo`2{rG{}!HtrLoMebK%sN;F*;Hs7}3XA75CT*gw5VsI5zL zj1ykP1s6LN#iYt{=za^DsW0-s8W8t>7HE0UPY;{=CR!NYwTcI=NI4v3B%aNRZeyq6 zIHnWa*%Z>?V0|KGUO7nFa^W+o){N2#!#Q?7&?pnCv8hJScdeSSdDwQrI7`(!6DwhQ z3KyNVzT$!boKFjGxx5qGvIFr+_`X%Bql0}TgW<_*Sa)I@2>FG>iE2_2y-tpiNNzt`SR zP} zZG@2)Dkop$HXF9r{@T`XsSfj*Dm{OdEe72&#;#}8w4Y?I*b-x8 zgdN5^q`548RWgz1^lO{@ZDwcvFCm^!Us8;d&Upwc3+VLN#uzKac^;gtftpA0IXz#pi_IoXcnGru7j|@kWE0IcY`2+2SV4l6Q`r zGgD+}#jKKryQN=(iGLO4xadR_z-e6Z;aOJI|&Oeon_`M z2m^V^Bw#Z=5DkKk--kTRx_}z}t3Z5$+mMVcAm?&=G@mf*cDH>^M>-4nJG9ep0VfR@ zZ3$ww*=Jlq+R4!t&kPXcIgdT3+U{^n_+UR=>~+_I`}Nq&k0*o4!-&=JxF#eVn;cf^ zB$~YPSn_~G;U#Ir&0hs5ES4QjO7?PhshY1w1{dwlVjejHDI^o|ae-*Y$Vv$$#00lw zfLJ#+cot6=00`wz9J1B78`+&$6|=P$v4~* z*&-wLF8t|}cPS`pa;g?`sM#vUlS!L>7ufvjHzUFrza7g}pR&n;=7>zoho(GEYvnpf z7=^*6=j((iI}tN$Bl(E7oJ%3{~Im_tIH<~>NeHG-9dw+hcNn{y3)LKU%E1K^=e;7;vl!d>b zQ}rtOr^*;1P{5nGgpuKxL(a6Sps}rMxEMKBh=13mEI3j5B7vZ70Sd8Xe#I)Z~jBi$?7((R+5&(?>M97dys_H3~`5bN1}c}sSs z9&41=i-?O(Qdgs}@4Xfw)&!pp;N5N0{N66cL{+TGPqj{!$z276H+6DuDq$Kr0}z$# zDH<;9IOa52A~xx|qED0f-ahx5M6XE5{;0?F`Y2~(l2IiZoPMM1@bW2eqAEkDIaYpQ zO-b5w>CCOh;FnX~caG-fjrFc;{FVWQo+#VkE_s^{L62?q*I_Rv>uWv0Jo7rGvcitE zm*3yt7rY{yEG&D%oj|>C@)?wVc6~VJa07XMF%&TpTCvh=!g(WgF>3nMS52rQS!mlO z)=_F18jJ;S!xG4C9!-(E??5xh)>^D0T9Y8o;_@*j1Y;!h5qO@c!IsQKCBa<=7P5hj zrXWE}QXFl>H(2|t<0fk$F~_}DM3lS5x%1Nn&mmXtx%%|5lonstw}S59)xqW?=9*&9 z4psDmwdN7~oc=h_H1jDtD(Z!bvYDkq-P;u3_^`k63Vvo4OvU1pY)}{7T@+^f)P5P3 zd2sba+vAH)L^m#sZiS$f_$NIU9y>y{P~ZqXR7E4A=1$}9&snQx~2c^7v zSeI@9JOy3dDK0euYkq!(_3h=1;k|8bC=P9no|VmiMn4eW){9QCO*X77nPP48u=cM6B)_EB-W55z$-8XVC501nqNM zU0oN<`PR^K^YL5IC=}=1onuURJiB9!w;qfHkBHuBbFQ1@(`vyosOd+0v{D$gXQCmv z^uE5*`BGy*@oSx{!u01}o*_#u@ih%xyosleB1pIhy}mU#WwB929I#0ZQcS^O;{jna zk~O`j+)A6_fS3{yDRNCfMvlDg%z(w@R{V0~pP| zkw;;)eazQ5lHQnSyQoz9e4gQ5TjZ*P7|JrJTL5w*_jbUG(5dTFmjqf?R z@c%xb{597650A;}12LA;0{?h#Z2sGO^M85FxuCr^fppsTGqzwBh++&Kf+laIL4Fz| zj6=c42i(~zePGVE2No%rY{mI4#ea28beZ#>C6sey-UfpdYy>ZK_#@$ za@Ku+R8vud0}(fpSwCH$)N$&1x*VttEL9t{Yp}X~S7)Pf9AH*R8^b-7e2*Q+0= z+Z(m$v9Lm2^JTEC`;gxk8$W8Ht~Ohi#jDNU8=)C5v18*m;~V+a_x1j;VqZgTn1qqT zK2_gM4EDHTUm-0fVgITolEHF;Z=0+qR9OyBoV9tz~uB!24C!TbgDF;74a( z?0}Pd-5{uo*Fue;RZlL|ATJqi-)5#&0k$D}RBcvYAyh0)-I!H607~=Spij)eyzBnh z^=xDhs=8hW1KVdxK^^{VM6Yk_<4F9OC^pf)zrvzHnq#lTYz|-gDfQ-x+*MbU|KQy{N6}?# zI#tn~nIQVZ(gdTz*VKuy^0h_KiT605=c}nOk4s?S+rVA)_pW0F>=6!Z*YAse40R;3 zCaSX-W_dLT=6f`*?6eq026=I7(NKSAune=cFynt5GXBd%!@DCOO!m)3m5(sQZ-WcTXQh4?GOt ze&54&){(443L~n)L9p_T0oY{vNlY7xhJsPeggM~Da;rR^$_PB^_t}o9aqMl!^mr-U zCmF47S;3d8497L1N9Der+{vOqX@8*|ALUm<@oE{U6Se#LrCrT;BUCqMGW+V$raa}6 z^$Pf-RUC~%VV|h2hnvF|4E^C`^x|}TAWeWOwCdy0?R6aJeZg|E0kmFJKiXR>J5JZE z0DsCNuukcfd726lN{mxme&-5z*T07utA+dS&6WHamve6YP=oD!T&wVZkbj)Z;{OQq zevWSSSYTmB9S(er?yS?`_a4dqNb2XY(5%=m1fx!99mse9K(72tt|r1X!?PAL`Noa! z-Or5|uq(e~BqzrC06cQdacE2$Et@dyjV^Zmj~mK}3NQ1L9yfWX@J9W(X(_qm&9@uF z%Ugz~q(30{i~SiOs7VKKbG^MIZ}}KY?(~{|xyx&9BIk}(PpXe?Pp&Zxj#n5T1`%wH zX;O&79vVK8(Q%V=NJBTFDG+eN4Q7W*og{wj3cY)n2X4LrJ5tWx9vlurjd-U-UrAV> z$twb(K;@e{{F?mkGo1}0;Bij38ONi^>CIzvUI$s;_gwbR;}8)|J#MJ)D=oYf^}V(6 zZ?=(zKU+^P4oRGXy8qf-WY-YU?Yq_MV^Ho87!BM+0yGm!a>u$1Lq0_Zf0}dBjQxda}; zIA7M+;{&fg7+_aP0HyG93dbJm$7=zlh^Xv8sP6YPrSZ0SY>tjnVuWW3AE(0yLye?L zt%Gls!7r7UroR$DZIXPP+?G5JRe7It(P_{rL)-A0hCySne_+U31=ep9skM?0=3-Qw zeG|z7gyg^Nh_EIkp&S6UUfA9rne$I6f4v$T=Qyvw+Db z&C=qu2yI{`sfRvcvC}1Y{>$Uo{O}&b)~3G4*-tWi+XVKo<=(zTF}ngS-g9(nN$8|P zW7VW$B)fOZC{@KWb~O2YP1dZJrZ(jUN$;79_ogQQRw5i+9e% z=Y~I%PROiK z_|emUEJe}_YZ+V8voMB>D8{xyO*-Zo-Ar#6F=zT;rtkxSx4TSsW?}JCh@GVe8>wGZ z(rxG0?tlKF!G~j=Uce)(n%H^!$y!&Pw5xqhg9fdy;5kuT*pm}(Hs6&v{H6_Dvjqwl zgNqhNW}_JmkIc8IL%4;22d@<-XR0mN1#`xMo0vP!a1@kspogwn$88tK$J1zwf`8FK z{3NKG9nS4jHfba7tbnE<+w9TdLin!y)zw07B$Fy4qC)azMw4M67t4n0Q}!^cJpKp& zSD9&r2_*!M<*6biI@E@YGKMmMDs=gKi=16bwE)`SdJuAXMAJu$n%tEQsXD*uX#=M% zHm2HJ3Oc_lr$DEfiDQT zM&`Kq(8a2(lQkqS!clyz3_JJXwFQtA!^d;C+2_;hecWM)BKL3K{;Sq4t zK#bQ2RpG6&^tgc?Q?cIJElruRdKF_;QTo+OKc`r7%ewGjLB559nvIRbS^kUDc0!i& zUL^dxI$}9wl@4vUBUSPDd$6k+i#28~jCP&Y)K+>eoPxrK3elpF1vPyRe9Ua1#iUCr z_lIR#?2$b9qo}fOks5mg07VRe@=((c9;^!&XSbmSkuQA71e{H8;*80e@18{2Tf;cK zj{dr+Ng^JbC%Hf;gfdG*FbaLjju5_TJ>mnqMeRwI`Hm!tPk4N~px<>37psjqDTgPL zIy@n9DHBT6p$0)qC$6cmEA1*F;&g%L+YLD&C{7`D**p3zTy)L|NW5wefRbzbJ3VVl z-o@6dGNful0945stwZ$NIf^@L*Ypyxz8YFpm?3Ml-%pplPePq1K`zp9+kY;_ z)yU-9E6 zu?k2=UE3EMTTPD|R5^%$T`s`5-^jQS6QW(A=r{Eu1dne;G#wQnDczBJY4re@Pa_SjVFpLyY91Lc3t;)a;b_R8LJ(lK^7~a%u z*3@o6Nqr-ev4DE8OBzP(+HWKfHr&$Tr@BLk+>!KtZ|Lkf7-4>kQL|>eqDPY4kD6n% zdlG{$Mu3$9SYwRm7xCpWj>Q)14Y7U}*b7Jd&?^~8)emR<IsS3${_taih;6~fFA7)a*bs=P+Xq7Qk<@RMnW88Dx`hKr` z{tanN`EVX~gGF-{Q4qci`}4`Wqn-?TOOt$gSes7dEBQ#_#1IM96K$$yP;iK&k6f9) zcm}08;OZxcxc`f$3m_FE0JHJNw^Yt`;N!!VB>nZ5= zM6n}p*ms%PG#~C>4ws@cbHxh7_*1*=fDGzaC;7GzlRUzTCjI zOs(dqfGfJjSZ6Kf?lB_vHfHNAxLOa^wqsGqqvY_Vf6V=!z&iuWILLdiFZ0h?js}Ki zqHZ}DnO+)5G)Qu=PY2?Z@i(l^>1+v<>uW5c$5>{+17%revZJ}?FL|N-4L4d3%e=pf z!W?|~Fn&x#HzszYEve`c13E+Ta|5}q5}f`m)@hdjLlvI{zRr`07tCZW8h(-ew{_NR zsIPJA?KjU?wdE07NT5wJMX+KUaeDCeqJ}%ikra`Qendw3cpi>)6fsK`@~hEN zigE_uJ!w(D#}U!G=Q`byGW;+|Ad)t+;`E`P{M7IX4^;MR#Pc!ilusPun<`xmI%7L7M_vey>LII{Ms}~D ziP+;khj7QlVfUKn<$}Ky0|#U8LV*dMGZHc}uS3_n@$m2f>SjbquU-ETaEr7Sxkc9> zk{n!B>>!Y_sRP6%EUY>EP#g)4-EBej2a=qRLo|D)1EHRr=!x4FWGBjgcGSR_#Pii0OO?#IDWI5BpHx>{!5ew^Oe zV$OK+0#ggm&p_joWApt4$Z-FR@T!FJ#U?=nG33wXlT0nF6^KKl{>&fGCt*5^tPSXA zkYGb0ZEA7r%N2$e3Kz&g>VPj(!|-rmK@1YFtQU^pq@b76dZuHlvN@Q zaXP&uBBLHH5N`_yw^O?Q91uGI%RVGUhL49ZBrzMq(&{PHqBq{k4>80X87o?Tdtjjk zH#)cy9&l^IS4SThFY)LA->w0^(nxZ|9C@fC4CSwH;}krx+s{A4xr# zDAnxXbHA(~!E7)M3SgF;*~w->0afDUFjQvB;dD|P*R?k&RG3};!dct`(8$}_MH&sfBb&QzgP1gR;NWF6n|KT~&=r2gHp+zu}s zdc42%{8>Mf1)Se0@S9-l<^9@TnQbUO$TYSEx*6}U709g_xoVMN9A!&kE%^2eH@uKq z$e0=Y^|0*8Lyzj_9DeaYoc2n`+cX9nY>i{CkLOWZX)jFAC<(xqgSRZ0#LmY5ejZiF zJK`1cnsu=wlt%l|iMx1Q(o1v*s~%p5ODYqIha#ko1hV@-dWd%PzEoJDQGipEN{b+{ zG^h_AtzsQv3ziS@UJm?-8U-DtM$os)Hd%or7-lV2Em7+f&Y7Z(5aW+Ezboyw)1xnb zr`5uL!?Bo{aI%i^DiY7^bt)E5N`(?UaZfAk=W&IXr1Ddbd7;40PLtY>gV1>Jr1fJ3 zHPe631-!!pY3_W^xKhd0ef-PAeR6>cFnxE?=1tgnhy>;`Ax#dXDjtm(6~}`*?V>Ov zMID^^UyKL6bqh#KW?VQJB!Kgy$79Vg*T%f{@8o2gw*&#zsZ62}rzf!HpyS3nS?}XS zy8t;}gL>`vlI_?*r@42RFE|4{h_)pTF5g5K03=(g;nAcpU0)c3l;rr5|+ zC9Dvcsv;R%J9t@%hG$uBzw6r~p=K_$4bANR*jy@FuRCm;ypjdB*E2L~8|Yz)2dcA34|IYKtNP*{zY z=Cw2}&6F2CP)sA7N)ub=^*>Qp%RJyYi5mTTgd4-xLm;j@ou^0 zu}GA&smLCLbvhkHqSKp3(B~<~b3$_CN7b=x_8kdL1{sx}8|8@;km;UTc&yHlyEC*OYQ%C;;qoY}w9b&l(B8sDIcIs)Q;qG~^_XiXIQeq?hV_+j*=> zH=fz`Q89WmG5Jt-*Xm(b=-tkNyRU4l9ZmLdTf8vL&I6hIBGK!}aNM~`*G)M_JFVew z8_%0!dctbQlV4WY9`@GS8u++1UJNMh)dRY!9xCK@(Ja61?tDbqjsw<+aVgUoANqAf zDpDNmgz=u;&mUOH7n+bHy_vH+*mU8Ycd@Duis`C zVou1wIB|N7Q11ufZt{LU)d`YoQmQzbm3^L1p4D}m^_|=7g(uqW&JxTr%cka7dcAOU zZ#vz+v<)0RSt-v^+n59dQ{~eQT;5NhxqI39Z#MeFZ-Bb>)fHZG?rc!-7W${R@e1j@ z{*t5gC{jLtA~!a^St3;5Jww366dG+XF(DSUO@j70^Iqb^ z2f=ZAe(3D5ZHif5sR3y33U`WB-_#U_B-5E#-vO1*;reM+d9o1DYf@5P=Dqd3rm!)^ z7W&$fK$9$-z)R!h;BwS~uA+m~g|PL`uZmsV9ryV{BO&=mCso>C4r#Lni8nuP_ekwE zo+e^(6sEJ@)O7pgd_x{uP7$iacFK8Q1cvjrXQ{(xX&bX^ZbF0(nwUq58Wr* zGI-P6q3M;EanD7_*r1dt!KdFQ*$<57+K9flB3o>mhX*;gn!m6XQ%aRtsUa1L-uNeJ zv8c$lEsAToa%ijnL&T!CR)I9nZI1MRaN7oqbNSvndb2NvY)Z`VD()(P-p=N_OFdkPodJ{Po zxOxZ;_W;>;XWyX|v?e?(s*vB2KUU3`!#OKt< z7F&>1$o_d}+O*5EJ%4?4{5*T}w!Arh>p)1S!7myNyxuAjmQpN+NWOXr^9UybWp2$> zr<(xxPN*JnhJ7E;$2)>*Q`%KMMF9l{#L}$&BTCXL_TtZ%!_e3;g?3-FU~dYHvk^sh zlr`y9Q=S^#+t5K>55gu|d(U0p+na##)96qvYV=2O#DZG_sZmY=`O%A|g+iHU(OPph z{V&_nKDPa^Zl<%d!MtNgyiCjfumGjR@G0(3U^(0SUh@#M+s3y3eTw8toYhw_y?ERR zEsKv5j0%!U6yDH!t00>LB>_2S8i7hP+u&Ad;!C^5_xhSOQQfEwttI>T$7S;Atm^Hv zAZ|&;&b+W4?^n3&6Dh5#&FiSBJD&G&=G4s-d zz{X|i*)I8yA&n)sb$UlwlKm<^lTzX6=!nopkT5aMCY^=6nzsIjNUWpegcSj{3gs!N z2BApwgGXaCW{ZQk-#(ng71$T-5(amjR7Kx}>FMFqh}xbln)zLdX^n5L=vJw2(NV0v zmk>3p^Xsp>z+A_e2I`khZk9IGR`^k!0mipggWH8@Cqry5g?uWjxBHBazc%>f9HekR z^&m5hm09kDGUis!WwOkRK9n&%h7beRcQNh{Q`Mz@iX8rOvUTu+mO^CkkO$czc zYp;x9#k@!w{H5RYG14JgH(fC8B<4Y6dyVNa=NFPV<89}e?;|TumEJkek=u7>ACE-2 z|1^1KehSl%&kB5|BQwOK3sXojrd(q^Q~Wp?>n&RE_K%cO&aN|_A@%C8MgrkIL(*GP zy46X&32h`2o#i6L`QkY7mW&k&_4Eb+_9Po$W@kVj&1|JKVG(2cv6Lhim+bd;zxo$+`(o?3Pu9~7O&vhZh(!@6%q}c0jx8%(o=2TwZjy*B~ zgJnWGotzt8SzD%U*Ytv#(3^r60mi!=y0aH;%?^C&jONVH+0IEjckRHj++OfwBM zfi5~xdqud!;OTD2)sjU^GAXgna%X+xpv-f&y6qV`BJiQ%;Su|Z&Wz&SlN4JDMY7Rd zTV)OHIBMTOs*78ss=!b5p-+tY2NF!MlpI;AG%SkMqlZ^HR zM&1~*d(z$9`|u@GtbQ*x79aI#{`ZkY4fgoRf_pZHGe&5aqXs8LoS#{RPoMyw6Z~USxi!_vNY5qs z*{6vz#<#MvU+V7Uzbx>*L+SMf+r*-6f;&;ID6l9dQ+ z>zGlxE8kQOqg8LT?zof^4uskwL#Q*sQAu5^D2li9P%TwLkXP`Hlv%sz7d@5&w(8@k z-&F<|GK^LoC^$9Jq{3c{M7yI{;Kg+K#wQzJ3wrMZG8}mz!k?$#bW_i3=ArXD82Wpa z=WSwEpATPaV{zM!3PNv>`J185g)a^(rp;TK;)x#u?xxYtjRVgf8)fQ}sbH|ZX)cRy zc{q;iLdo@1QmtzgH(jUfTXV0_LVT)++HGa^C^uoq{dN6#`R{XUThJs(mUo#o5vbTS zzhwjFPomIR6yzNf#yMGZcFDMK|d@_nwy06X-Y_I#l!?1m+(^@|(N zH$_xu{>ho1;2O8M#a)=f8({6;QQYQ|i6Dv_vdE4Did-hkCSG>tYn}eOD3pyw};AM3-b6*o0CwyeVOOc4eS3R5OWqfWYWY} zba8{#1Y=?EE2e@8P7+_NPWAX0O7LlwuludmD4GPqCH;aApHW()kd6L6w zW#vj(9bn+5`tIVyHL5hfJ>Bq$uj11Z4co61BVWziJ|>Lyljqyc#d&b!_AjLtXDTO8 z*>idH*uz-|C0m@|`gwvxq8g<6j|@=fFf4XCj@Jz_lS-wcHh!!BcsY?pFJ5Tzdd51) z)qmn~4oexf+DdhmGl0=*}z`h7Hv(1HT#)v5MhUf=Mx-0{6?eVuwE$Ym!b@6XLVebUk@Ry|ac$ zLj!@E9X8ybyQSu;8d7>XN$x?jimgECF6j49!z@o!^vx$NR8Zk8<9$+1$i-UWmTJ|I zSQFk#g6UZw8@3dI*{`S!2Oc?9%ti$vZTP-$!@kkG!9i1$&M7R{7**@DVOXkUSRwu^ zGg&II{BYoPA~r_X+V&tUCs*GTjjXUb6Ad_EpMJ>y36HSgt zs9ZsvXg%XoaYRNYVrefG<#dX*7JVc*br@u-AM}^odG9{RYhCd%?z^dSHvx|h{v;~N z#xx@2o!~!I>RLZXPHhO}jN>BWBnD?Snlf+=R03Q%I3&V~2J(V9h~mHTZr`CK*&hA^ z%+~!A`QwhQu~K$YCvU4KG>aMUx6bVMjE_eBSy^e@j!XfqQu%;upiEzO)AKwCKy;gD zU5T)a2djiO0iqXtJTejnuN>syrD+d;a=K$xl)&F<7`%P>XH%wTiwjV%axr%{7xiyN z2ihx4J_vDh)jaLq6ZLi!Q2AaK0nn+x8q!KrBrBaOEPnY`hcj5dpo^zFbSWuj-kR{c zSgpD+>8f64C5FkO1?RJMneN7P|UY*wDmu2Oi(5LXH znskZ$vk6Knawqo~?!2^KYkVG3{Nt{0u@1RE>zQ#J%(H(|nfbV=Z`&}8m=ySGUN_P~ z>07VoQ_ol)z8V-mxn8}K%187H*CJg!cCw(CI(er4H$Js=>ljgca64W3q{ZUa2`h8O z*v~exjbEwdO5`&2a-c#7w{c5Biw%xyk-c$Z+2D?&)bci)CthS%THMF?<&#CF#(i}p zmiMh&X75ucvDOTYO0`u#!O{fG_{Z%ep>tKkmK(nR;NjF%b!l)oye!t5YrU%BWx@3S(`XHpO_$^kK=eW z9IpQwQynv)f#q0#Xggz=pm5jX_u}b(`Jx?uBK!~bn$Afd2kTzPS=%^Z>3A;r7R~Ue z&B)ZfHw#tf{={hg&iUd{MsIA&X)A}J$U2u>y8Yhf6MAfq$6p097_*!n39x(5+N=vm zobi9i-iAGJ@i;5GJE5Q85g34>gy$Bov*Cqy<{&Idc~?=Ekdi7b4xcHV?o!27iAN>3 z#yPKvc=sihbWMMTKZm;~kfo74^!}4V>ZDH!Vqe~{SQnw1hse@DbV9qU$NChm|Y!~cW3Jd%;iVI`v-}}ojhYNc<=5a;U^8{0W zr6Fm1&MVZQ)_m^2-^q~*p$N6n+Y6l6s-kekQ4o$ps*OHf5Wcn}sD#cd)&}Y>GqD+Advr10>C$%+tkb8|1O^*@&>JcSB;5AsX z!FgTk3orRaOcg!C=N9Yb&8soy`HxS9AV|L{eC>2KkA9o9kPsm-YtssiQW*ujCC__3 zFb}q9(PrVcQTaQFO5p^B+sGkogqv?_AfOcB(H7Zt@i1a8__k@$e!$y0RK$_k4EXWz zgFo0sZK~_g1Us~lY<-Y>=PshG7;Mt{6WV)+7VU-cA9#o%#dW1IBJ?)gMaBdq+ zL+PN!CJoZ(?`aeIkB@c;vJ;K~n*R`X5lN2h(L&f?A+84yXI~q#8xEgf1`yaMx=IK9 zuuluAjX)y8UlC#=NW79_L$;vIDiyr#21s>42^ormyU~H=QH0kdUqtZm16oLCJkqt{ zKnH@J2uBHA2evw-h4dsMTQlQ$k)uBZHw9%QW0}0By+&#u(Lx^Nz>zkPl6T0~qa%{5 zR^%o7_Yz0*pL2il9@&HW@&B%H2*o~ldT59hF#VVclGBfj`F|EcaOYSQ;y8fxCToQv z^Yb{Ng%FJ)(d}JiR~dloaP!YGB$EDA2huf-L;ydoa|GK@X(8YY5+TvgiHs$1Mhj71 oLL$#cDX(Ldz|Ew~NaW0!2}F4Zi7-8YN+cqY)o&0mZ?{h!j_kY!^`kXr5)vMO( z)qVEfr>auHO5Qp)XCkTVGf&S98O7H|FfIU)2w!Hb<|JsB} z672tp_kmLVH~zo#y%Ty+RMK>{1;hG!Y|xBs>O^Q?d3 zeGqj2jsIsY4IYphVE--*03UCq{m(=GX^QYbKqx?jK!y$u49513rhiX%W|rpk4j!s1 z@Ia8B9mtmRGsu?zSQmF#AYiaZU?3nc)W0*M)U}m2meGAzYGzvzgIW6Om^fq8crmpl zz4x`fE!XADS?lQ{Yw608;quIxK}lD2aC2qT6>@<&0G7fV1`F}z!w5IV$8EUN%KIY8lDox3a6Lw=6s?^L z3N#Jt9qHb8xlK0;eAPzASCm4YKFA;G(Pt!MzsRK%OnRb9?pSGjUwZcq0i%~}(0Es- zn@YP40Nf-L+Ekva$KNu77}$g=L_-efw7b!pcfnU+UPg{8{0aNTny^onqsK)3$oh=S zKRAb&B-3YDDlLR)l(Q2HkF?9WX}fmV)Q_Itf4V_2NP44_1SslLVuHtgYua#aTebyh z+bk?5eoewF4kwf>98JU?>V;gvw%Q|Mh>&}L0(jQQZ!2$QSPp`@vvxaF+SQ!&V1|An z*56@yY#mdKmR`exz#(+teItGda4!1p=H$lsGvQf@EcHDFfS{lhsFly3B8nL;R+W2y zWOeos*Y4rg{_jq#%pLPv}G(!a9Fz;(29sRIK9M8UlM6D;#$7DB!Cd4wE$4 z0QB4@t2mM=P&=C*0++2Mbop+8azivGx97ZJ)v8;FW4obZC4GQ zT5*@Zq#=q3XJ26x-4jCzrLqqO5#me04AUn{=yM0IEFR=2_eJ6SrIaPbGEZxj5+%qe z{kwZ%+CjmL7`f8XvDjscPs&*IYsvh90aY7r)9RG@48y059hIN9?v4TDu&hQJu=_6( z^h_p0S9z0d1Kq9_AwJ*4x=>5gDO<(g?;)v31$c|0TL?G415GMDHT1TUQTCHScJBe* zoTpFjp%#gqqTUFG&SRM^#9S@9f5de;_>4Ra-eBd4_>iSp29(EyzIrYiS%^IA0Sj&< zV3IK!@3Oa)dtpd*F=08HajgJHLZq%SuI1xb3*1P`rUF11|n+FijcBg$_VQoro2g*?aZl|SR7Ut&ybOo2cYiO z2aT%KBq?&QLo}u}!~)uVKn{7RTpGg3CS{v#w*j^Ou_-rh#gpoU?7il&!X<>n55Qw+ zo~Yn;#@#^3;W&@D7vqf*fB~%3l0jkoHrx=s7*7mq4|xg>rb8FQ}KhedV@A##AMr z9adX@izNKBc7aA;z$Os1F<H?1TydfK|;A5x?mZTB2CFfXcQ% zY@!ho0!3!Nw0DPz1!}MLF=&mXyZAkLk;EnY!{PMKFQ8>3GbmzDt{UDA zHP>aHBxTmCm(=g}wOhI2H!g3$ zC4d5g*^0w;05s(tPDscXcTcjxtyj_S#wJ0b{d0@>ssz!A+n7G)X97o9b)6M>1Qj8( znLNR*zjnZtE86l@^+#tA7l-3cTh8}g?20vJ5oVN#Wy2qI=Rn?NH9y4G00!;6BkBC*xJL_DDh~WzGWp(9jzDemL0Ie!YhA$Y|xwoRqr6Yr9?=jyo zY;RK!wY6%?=a@qb9>=oKHzjeJCbojXvvF&eQ>e$od(hZmw^uMpjmSC!Ni+i(CR}M4 z#!=0eihb%UOjcVEaB06agbKkNT?aZQItN7~P40?<$NWY;eoNJ_&_3D+7Fzi2Jq}8f zipJnP9^{i}aA=8Fco5x!8m^1g9R+DfD9=tVe4f9l{hxfSAPxFA!$SQ%{_jj(@Hgk; zB?w_)By2w;!2X+<|2M(wB+OutCJ3T2fhNacH#noc07L$DEdO({VFqIW_+Kc}(1W!M z@h=o;z$C)`|AQ3tzW^eQgc4TQ70>cN{eOVPKk-u%=Kse311ylilXU)zB|w;ffXEX3 z%+M32NHG8rSwNOTWk8ny*#8GQOyOACZ-_m0_znpQ5m8l?T4tbjfOPt8Q0f|I71nOi zp;rslq8vJsC~Cz^Sz|(E4%8*AOEz<@+hz_}BG_+?%4KYE@~0Ch$HfaAGF)T?{(<94 z#HE;`_vhYs*k-!!@>r2c0F0URukjx8cdo2|G%wh+N5D?Mk~Jc7xy>{(WSIso;9N#c zT+*~`wn<;w&(yx+;4re(@$qbQ*L!O;U3?)z&U#thzO-suV-_JVaoe>`NKYRPiJ*j@ zEO|~fDi-9WbnZ0kEuok;sELv-E#Kf6>36!lEoGOU54S#FYHTfA1A?{2@_%~HP3QX> z6GuR}Ovt&r%T^U?qpDP`I1Qzj^$`?QzH}#FHcqM_Z~_=wUNNjj^buH_r4Kr$FK* z%qoRgk6{;p0Pt}L;bX5)4hQN^dq|D_330R2XLa$)Z~6 zO1wy0t^`i)Nrfm4d2bCQS%g5Y*m4hK19ED@9QgE!+yge>8-C}@`g)Y<^o?r6d^|^$inMMy!!hH6zv)K*b+B@( z44tFY!(|tSz4uz;%H|-EN>~X=3Nc*oa|}_ttJk^M-Lb8y2{nso={mqFjCxa#tP2^E zuHR!T7fQ%p3B(C92kd~|5sLfeM3^;38Wfg0JY@QO1}N{CbzWv@S;kjVRIf=Jg*jGO()-mZ*6|+4^dz5ATrP90qV;FM{YA*- zsrFTsS5^|w<^A`s5_uTTaQFo5kNf%G^h7u zIj8d4gtM>=y?@qNOWxUaQ@Ro)LJ6A zleWy&q0nYVoy1`-)BQ`!>(Aby1Jj*}sa!zGU%?Nr-e}H%@N(?ECt|@+A zRRI`*^6$v4m!vvl!GAl1?Xca6wh_j3Bz>&8j(T5VqEgabR-J!4+}fO#U!PLR({aLKFpd) z4UM{|1v(1+BM4;K2W_fIjdnX?e~45_c?D=vJ+th?2k9pJ%4vtk1}Gu*F@jHLGSkjf#f0|0vK?DY0 z@!T^#A$XBq7|P86Lk3BOdU{JdYXtDT*=C((uAX_$vGJ`H!x9t71@;?`)F76%g99V0 z?eAL@Gq-$Qf_gxNeASgdshv+zNPuDwNM|FLqadp9ZoVR{eN3VgD zGgBPy*N`tHNJ0-c393{hHgpABN2onGJ=jD8qDKnzn~(rQqBJzgugWH1urIWMd|F?wE|Gi~7;WE~P2iA#mjb}+K<2mu%Zlf! zNz?6iWRz5{6m4sk(YnAmPLs1DYR)WIs$4u1#?3sf5o9xoU>&g=WO?&9PpqeK@6%v_~bhGn~u;uJn+ zckes&vWFBt!hd|=T+{&gZfbUo%PSOIWwLwRP^7GA(W9OC0!;C3Jxk6cc;w$k_*+hE zzxYkw#|3z3Xy&C?w#RU|^-a@`FK-(vzucFMAv9sah*yF~ptcV@j zC@QHzO1w_qdxr&-6cpf<$~l<1F9yUmK6;5nODLp00~#;OM#o>0&vRwR89Q>0xrT$h z4vW-4FF%a`{-FZ$8}0_XK2d{K)>~J!c;rhK8sNU1L6IB=Q>hfwGr1&{x>CCxILWSz zOt-^^DS@KHn34b!LPRT%Q9zf*r_dzULNIVr)L#a@Ue=?BaRGd9Fu%V$_eZw@}d|W$+($>K-M;8w(k6U(|OSEe~7vJV-w5 z8v@J+4C_EU92wUk{9=wtT?hiK;k=0%!iWO6oaw$%1&P)%dmfgr4FX94IG@}4DP)c7 zdc>u<%~Z%>Nz#oYP_!n#19ldc;0-nr!h8G+27c!uvpP8c!NfrE)O(K`%I{4vo7I1V z6rhGHV5JA@`VOdIXhoK%?vt6Z=)cdnoLWJsXw}U?Uk$g{aE154AT+-A9CTOl{I~_= z`#u!ulJ6LKA-BA0(Kb}4TP3j;7Zj6K&SPjc`d6iNU2d<85cG~ILjZo%s)-wX7uNJ4 zA#Xft7}gl^i;k4KuEG-BbTvatHwM6Z+iToS!zPJDr~AIyBD$!UXbsK#z86WpZX2Ro za0tR#w&(76+K1Z_eE7J#L3lAKm5l&c$v^Cj6?A)Z6kLc-ITNCmJe&Vy>%9Jzj-JNP z{jbch6YjmPxXAYq58ro-OpwFP)_#RZJ$SOy?O6mmy+0(sPr-cHeY^KGJ^g6I@}5Zp zwDUCx67##aPtSW>+jdze9J=FY?KUt^hS>GtH>wtt7Rj4@!qNYH!W;XNGm+5qaO91U zb3c@nYiTM^nwAkQz13y2zc{pVy#9s^jS=!Dv5#>}^E6xYV7+9uZ}xp<&fCv;@@>M? zflk@%h1C7T_=gsjA*ZCk{iTHeP^szw{5+(jHa(sRJNd zQ)OY6Z)j3)f~I*V1%Ze|Gg#ZaMp<8}#-eQe%)G#Idd0+w^klsIU5{s6HbUT|~CArCU?3*X8+K(X3gfY70=WSeY`4k-YxCRH?ez z786|vBjB$f8FBHHa&5{-gU4ty1Gf~iev#4MYD>424Y7r^uGVadbX3D(hqY6~sXwAz zjXYl#&PAjkM~LbO)^_vs%xB8NDp9D)a~R)cGGB}^V>9P;UG?Z^r!6taFv-V>Z(%yYB)Tqxb9S6`7Dy& z{vEQhW8d#JFXxMRz(U^-Mj!F|j1ywi6)!R;W{sMc(lp8QxaVMgnglf50~t~TswpQujX&03;{YVA*7p4#^_ z)2e+oIe&=P6!V;NFo(#k$(nQh#QTm?$BGBl-(FI^?pIE>ikgzm)shEeGLh%`rSDz0 zz}{@fp$>u$xbl840vzZV-w!?g1(A=Y_s6Q&DGRr>#-mjm$&ARuTGo|2 z5Z;t(le`ziI$qJ&t0kAymfQP*S@!sB>M2yw8!hth964Z_zkH^88uxu%;t)1;Gy_vM zq1bSs(&-uI zrx?7CRL$C}+*6pFwgR6!LtomNN-QL=k<%%w(-&3tgt%g=d}c>QCVX?o%(ZHK8kO^8 z8h{J*vq7rs($NqTdXVm1fABT}bVBQ=F48btW_!1<|F25mI;1<0RjH@?xxpL$PF*C)IY7xLTM64r_v+W%DbWDCK`%^w88cYajK7i6 z8M~vEDlF|1p*?D!R)FC=tXNxS@`*-SKRJA{)f`xd*^aa+SuK*JxdU@&R(#%gFqmTXkDYF*Akih=M2zC8;FP zohB_eYZnd-R;R9*MZAT8xU_Bqbil%D+2o$S*F08O*|XoRwx<+t-0&OA^%~vvqH_7! zhc;qo(*iDMXd67YGR4=C!5^!m%CkCWa&vCg$POZEr~vpnzrgiQk-@}J7b`)n&`D*g zG^JU2-?9@dY2R>8zR2|{%HE&Apu0C}z@nN^21;xuXojr67mKZ2U9g|x`~Z&?Krrmg zwZZH#^7_I!+>fHJ9}08|^$pzeDh^53^17 zN4RxPMTuC;9xdY;qtEqiQ3GByEW`FxDhDx<#(dBZIcI-%^7n%NC8U8;vFc+9W8}Wo zq;R(~p%&R1z=qdI@RZ17#%v-nL&^! z7II~Q;YWu#T{IjguD1H_dpVp~Y)w!!f4F|=0PJv0cm3jmuHZnTRse{f2^D82{)(mr zsx-+>3P4n*xooP$O7SLWq^XjSmQ#T3g&OZ;Sc1T1|q%^hcNgalC=r z#6pnU^JOFB^JF(OrbL(PX+FTg+0C|o%QS_R1vTNE+CfXa`%P!OO{2rHWKwyyQd{VA zN=C(wXN=v2_bnnO~DMEZ_hRPHJp~6An%IEhp0+#%pCk%Rz^qY{&&nq*6f`DVX?Z@m73Il4pTmg+OvhVYrvn=}*3MPi zOA}Ko`hhPK{Iqjn;R+*O-s3$jB1_QG8|XDg;CCkvPfHCO87sMx4;!NMK(>T>m2s&o zMObQeXdC|Bl7Nz3)2<8dvPc=XqS=E8W`x*M!G|9-H{5vg(e?e(&2M43tKrPlQMW`T z%yjK!H5zN8eH&SIWZm56vH_Zt*&Xlih7(NhHoSI~_Prz*&Ie1Hh$s`QoTlt&tXYbAOQHohphxns?RxD!oqyy5RFI} zy1JEZbqtaoHA4vF@1A=Jnbepx87Ak5Sh{dmccVrG4~J4T{&EmH`rI&rActTdkfH(G zL|TkVDRO088!MRP;jY)N0DLn!DxEykm}XVvG_Wo#mdcD|ZV}{Af?FLm>3IC`!FNUO zt3y~TG{8sEkBy87m?pU*)hRID-{J&DPqY# zj})W~xATFzwAV5QnJ^oRH83)@)Qg*bt3d_Gahu4azCi=`*@u2$XQYG;-(uK{I1+}? z+srPNcv!n-RIChSqiRKuJ&-ujhi6LaxdZ!TIsjyocT!0&e8GPj9gDh)TX>3~ZJ3BO z$;FWe1S{{fsEq*`7`r9Qu@dCi@V{O^1tyfsrV6Cf5aU`DD!a5ao`Yg)WuWQDEk4av zvcvL{5LvdD?iaGipqDZ@T%6#4+|258$5eeD9a^wh#Fi2=xQYq=7-KS;oh6n5OV?6W zX9A$_R@>y#Ze+I`*(~w+SMlK2t5gkC1Iq?K?`xt8m7iLmCA?5hUODNwp`S2!a)gQ) zo|$ zJ(d_N3Zxa(kJST~jtLab-bZ;bcQHA*hr4KBwdMs7_Sg$Y@FK5)hDE_|hA0wxNIkXn zOY5?!22e|vSxmDWYykYiZ)+5R0bDYkdZc&BJ8d9phe4|=e@oq zw>+TkPaX|KL_M0fJ_$Ao_9i%n{@5MbWyRu-SJr5dA09&A=YT*qw~~tTG(zy##F%R_ z%Fg&nC}lVPLrKLv{4Rr@;-AwQwi2R@Z=Qcw$dE zXM*d)Mq~n+<{Y8pd-z4>Y}5&;)_(AX!MXnm+RnV59?ii{zu#|L(-2RGFDA45!)vDk zp)P{QH2aFlM>#dx+?198YRglBN%hMZny8MoUzqvYS=Srpo`DGC-h+spr~p74D2h!H zEx8zGewHtBoq*UnC+y~3N+OX)Up<$O;$wmaKuUqU%0R^{K{yl-5f}T8(}9NbRq6D|b3C{qr*9S^pDh#urVd3hcBHAl zx3z?a>s@&3lXl1x)#6acw*%B{7Gg;x&+hSN#d^8{h@*BeGBn1ot}wJo!k;J-lm=EB(zk65U{y`w0fJ%wQ^1T>0i=K=jm{8) zw_F&|F2L2RScs{C*Y?>Q_{Ye_*Fyjk&f#^RB1}wxo3}R+SIfE*6jl48h|)bW(q=}-WPpanl@9jtowt0T*)BW#g>GSm4x0lZ27oD9YVL%1f|O2?2uu;J z#wYz0eqKySRKIPLAHI|0d%h*=F>3hja5wQb?1<|F-tYP1=*cYx=Um_gvAMH~rm9}{ zY!Iwlr6gp)F?BI9)|v2ta^^<}Nts-26Q(q`VI}n)mDKJWkm4EX#VWV8@SDRLoT{jT z3^ip%up+w<7a%SZ;e- zMslwAx7w1^FPyPZB`D8Yh#Wbur%|3~7*uED^Jc(aw42?)2KQlA&2i#@G&Z-ywiIVG zpBh=xj9A6^J-3{WoRhhNGqG$00^4IVCDr|6$f$FHXoNgU->+@Sd6!Qv&0E-#-od^8Z42ujpNLn}3UyfIhQp`ZLLQsC9r z$0S7THnE|z!Mrmm#Rhc-n1y0%zEmm3!?%5?UT z#rz~n^mt6yauoi42!*Ae2tgL2cVx{PimHWn0kZnM=QSURiS35Rd0fD_%|r1&mwy26 zF<$ajWCyRaRM5Gh&p_N<3~Wr$kmf*sQ^4bHUnmQPuwJdoo6|$0;W4$CwxOX zt1}e`+7cSyoXtBn0lazbU5n?sul27FBF4r)Gh+OI{lZ&yFuzxGI#hgFXDeQEa**Z? zD3*=z&PkA}{ov)Ttb(<`z{8M#+>N*eFUaUs8%0&cGO@~W|b;h42k;TSdBbvN#B4yQ9L}V!Txa!L5S)JZcYLMO@ycc z#Hj2q;~eK31^B~X-gLvG&Q#gP`uB4ZF4y>qO%!>kbL#S&QZLh^pFv)uwYoPA`aTQ{ z8U~>lSq+Q$N*d0%lg6<7bl}AJEInO?tM7NV3stM<`y}{Bc}YVMc=kgA0{TbQ_&>^v z1Rz28w=C?x;>Q17eg6o9D-a6)?=HK9!Ekoaq`%tBXE+H2L1S`0nuFBCVml=U*Hp`j)6?*o4DrXE5pz|`q&SD6aFAR> z2{3UE3^E)8dZT|pfxM4Ynvwe4>90CnRa|%ZbR7R?9F##T?YKVQbeehZ282Iq4NW4rh@+3QwK&wOJTarc_o&sFR4Ur{RiJ-FKE`JV@axyG zmH)bqyB+@QJ>#U#ox=Ba$&{HU%Hm^);*M7WzoEK)ioKo+!A_Pz`m20dy z??S*^>>sutL*UbRuD<&W0PaD9Dh*z^yLQ_~y51srrgg;K5fU1%-0A2TeL@5LTMs~-AYIPr!hL!o2j3i(H)DD|SAhH4jc#c~EHiK-0k zSf2G8^;JO>bz<7)0up|Xf_rCL@dK<_rNF-~2&}=1?s^5Y9J1MP9rF_KF=HXEG_Xk| zSYn19J_HSXUhB`_mud8tDnM@*qNIkZ7?=axzy?qAfMI<@r#3+zY>6;YN>d*L-*8!t zH$XwbYb*>QFqH)`TJO6-LHAY!eS4)5m_dY_;<31anmnsj0I~5>OF%SPm8xBhP1m9U zUz)o;TrgqqReL)}Q5?S08#!#(z6+g?LicA=RW$ZDh4)ho zdiFk!O--(r_Z>0p*F98CR&445Hr2k1o2@+_*8yq@mX#`vy(ziGCDkLe;GL;1J`$wu>0QdPgWqMi6E+4r*lN@bDXkDa?zJgm)Z~ z#5{BsmhHvJqa$Utr}2>vygnM~%8MW39_2hd2yer+0lLm`Ri>4iKa04Tp{ZlNtsrvJh?R~Ev3m`-YPpS>s4H+;wjp5^CA6S zp;})s{$b28qfsP0Y=pEt4z$uYpd3QUuJ=B})QjNn{ttZ66^NJl)+L8MaE$Tle zz}1-|?2VxLTZtbCGqv2!`89nysGlf0#4;J&Z5sz?`S9```3q#j`{i@_ zFoVaO-iA*Hl7D^e#lY)xc>1yD+WSNEemG@{$2zk_grX{5-Va8TMg}|D`osjb`nQB?Jz4i5FV|^e-DG?qZGsuzdb9^@bgi+EWufN^xn;?1H zw8C+%yQ#-j@Q3&4jf(@y4~cJPhNy>lKbL_RLnCcl;hh;$ceLc;`=hO@h9f>FvbY1SiwC#C$(@@*?<<} ze8(nu+AO{9*4tCVQCiQXj^0kU579WW%BH4WQ!joSQ*Jbt>;#=bpo>kZ#kHmqEP(d9 zE{Rz31zJjIt$}p)I9^++3`8-Lk4Xq%`-AN(qc6;z#^P zN%?VVb3u}1r3B+3#Gqiqd}}SU1OQC|N`fezCmKJzxgk*)+kiX{M%ELHiYT8uedWqt zSAQ-xaJsG*Q@Jb=1f(I0TJvg?$c;&AQX*%rq~9&4o#JKGgxUdz3Rq$+)d9Jlk6PN0 z$BcA88HJI7ZGY1UowOum&C4nK#tWp7%*iher%U4MaHLrU*@}X;xM$qeLZ#op&rGGzxU1lx%IK>)C{6 z)!H%VeXg891U!3!ngS+0XEw|-OMHea7U_w0YFhMq8R3>;echzSt_5f}zBCR7cjrc> zJx>}?7T0Uid$YH`&#~v20q8EWBXrfGDCGQDJ-h7Vi(%NoouWh=AMlTfg+(^xO zDV=tK2Eh_so^e@So0OIUo6L!EMkVGh6vF) zOe`y{wyfPCL*a7r{a(zFLY$%tNhh`v>dWfz#q23pW{_}Mp(CPCgiLg@&_W($^WqHTG zx*M85Xgv+L9lBHrcfIA9tj93Nh0qorAy{8TF{l>g3oda$8_<%!z|71Zhj;Sid`jvA+HDjKp@^ne7p5oby!e&n&}R&6OJ zKjoilU9OI5ozte3s6T<__iLOcz~*k?<0_l0u{B9g`hv%B!%RMj^I}np3md1$0;k}K z*}HZ{Y$B1Q2h1il2h*W{jM4a}q!DSM_Xt-@cuj)Az}6yC;Y*NZqLPQ~Fou{72dy=N zDNlf$59~`Ki~uZUmtSJfnpIf|K;qts@;lS-zzh1vS+4EPQ zg+$;s7Rgt=9ZbD^y4v3OhcVR{*ml|>JYs`_QxcPHkO^>g* z(n5t=!ZMN`B& znM=CgdNgAXlE_usW-NA9x9SZvJDO?rV%t)<7z_J}tLN)U_76r}<=@?8I~=455~QW_ z9|3$G`EU>3ZATNVL?)=0xpNFYnf#VHU!{E|Dg$U8hx*}asIC#uAt8~N!-}I;9&>`B zRl$oWix>lEjjGxvWh4Z;2jSI~mE|%;WMKu)^vy*<-D%^oRI&#svN}O1D^TD=JW0>! zAl8u|hT}qd@QRRp=ctz}o1RIuXgpBis{k55*hp-ZOaGzVI1-0?BZB(mPE&17%7uig z2A}tcWrFF_=IF?R>B-1GB-g+pWO9B{u@Leak6Qis>+*fGb``X5*UDg%b%uO=H+jV1 zg4yZAn^P@QD>=#HB>5FIni)b{W{HYNKd4!qp&1QE*FEXoEEtDaK_Ki+RA|a&D*zn% zfMCabDjV~sBgIkp_Uq~|WChYP^&gA579rIA`2=m==oWRKkSiB z(GbEik@b|FO!j_%!JGG2=5OhEnZF;6AAPq`u-OoPKkU;u z|FW!(9WW&IfAHl=z0L-roVQ04{L%zZtpwaf3SsHT z^M4oFv{vy)m8K+{`TK^C0x0xv*>@VT(y*MDK%tIJ}I3L;09Y zP-*H-1>sOSNct*Eg6Bj@97$W)vX#aBm4S$~;L4@Z*U%KdRK^^*JJSJhDI(5&G+v=s zB2=<0L(-?_`_E}Fmwr(^PYwrh*_333C*K8?>`fJ&HOzEn^M7~IagZXSVc*76 zPz4{MNfn=9h2GMV=e?xP{?*7AH7&gyJM#I>(4bD*d{$s0zk0N{m@^yd=%03Yed*17(y4 z5`6;xu%A27hK0t6y+j|ZCTOGkge{T)`gL|sG}K(4`}!gNVa@amNom?`6zz2Q;@8I zy%jv;T-*wCd2u^O2+{J9hYlV5i{1v`zSayM-}e}+1pYwK@`ckFOYCna97J476wKx7 zH~s~vH;|BY;s6NC*I8+`HWi{~XOTu1>ra%toj`Ehh$vS-u9Kd#2ynI6Wkk&?De-=Wy++GNm1*KYf zR6b+z#2tzaKd5ZV5`VdYsFFAwtQ#7kpNwAPQ%0~{Gk|A<@}hb0-o8Bu8HNuqIYEGt zN+#mZjP{Ux0KHdskZp4k^Mu}`gfOS7ijn&z+yHC`n09^L{492P+?;Oex%7ULJMU`9 z!1=c5_R60AWnZ)7{|#_=@IAixz3k=2e%;mdy1Q_c#gS2wbLH)Be7=uI+3Io#?;*@< z7R9nr0N6VY)#~*SJxSpck@KEKr-uz994DMm3c|JNvx%+TwPz=bm%(YU2_q;FEgD?R zXfZ9OZWB@^4oSKVyr>jvG$S^vO61YzLipwZ3)Z=8EhNBR zLTQcVvXh_XD{>dpLjU&alVo5hdji|&yug372ACR{O%+n)!k;xU<21kpx(X*MSdE4+ z<{sTNyzQ%)!Cx?mQy~N|juo&YlZxXE5o6kylZZ`wOdMImMnEZDo?JU?QxA+^vMaw| zLC@riRSu5LZcE0K5%QM0=XxzS(*vf|zbH}ctPj?Yy3n}$FfDs2$z;9lwfx2bO^!mpjJT3O z+D6oq=oao3lme;lfr)|Am3}2J9-doY|H)w<6_fBWD3Q7hI;K?30Nxe2(u%InWkM|) zhM-U{syUiUs1@oszEGcP0oaWT zOD8KC0NL8vu+d_m}nH})e_-}i~;#(^fRG9 z^ZV`bBi?Qxy$KU_nXbViBU}-?z7#ScFg$%F$cXZIQ71-4QG2jxuF0hZxB;0+5-adz z!EyOM+4x7-plFKK+lCpMG@LljPk?&*TxDvj*l<1gw{SEkeFN67-EH?bRO}&F(qv5v z@oQ+9l*$A+tsCr{Se854a-Y1bEXpP9bee>T6uuU@bS4~JaSb6ak;M6~N;tl6lWm)7L+Ml8rk`H+2hphhOK>$*^(?(D^VEQMwutkmuuY> zX`4K6@^lb45Bi0mBL@%a4FE-KKgc_zbhTy-SUq7#@8zajjSR`VV401Ibp4 zUbFUz*obEqrO*2C7GJ#l*y_Yt>2@B`gi>JCnXy(Z5^U;dt!cH*XaGFhS;slkXlc%J z;G03iQxOb)k7;TX1+=1z5PwVB@U<;^_vM4fsx!gD@tY`Pj0H+o@A55eJ3Fga&3i^&K98Ewb^^7SHc4$)S4Ws*MZ2V z;!KpPeodCiaROu{5D-^IQ0(~hN=8d8QB~2JF;MT&V>1Uo7Yf8>xr~jC@kh) z;p*HpnrLXkoKMQrZwZ^7?8l<$)HVHSBpQ%>2`^ubWB-w%GG81u)m<6*GKXvK0u(*w zT5BCjW`F&k`+%_H{s8}Fr3>jH=5+g5VW6c>DQ zQyU0X%V*`i8fgzwf;$q}LIltC7{U0AB~rzbmp8>CrBP-1Mj4}dN?h*-TB-EZ0_{er zY)f{zLcR=e4`FkmK`7v$`Nys`*Mik|nY~5}MH$55Ou+IFrDX-E7|ktLjc^dM1!~V8nhWdcT)?|@MYEI1|BUlQvqfe+ATRszXI8&Rg$ zmF!HvLYuaRX``4^XI3X>f}ZV?_&QV3yNd`2VDswHwobC`%df)bdAG2*n7 zn*cUC-C%2=+n<+@x{{VhR(|#LceR|dbTuFKgzib#I21Nr!SaY#6s}Ci#MG-4>1(&} zuWK5nbZX1NyoFR?rrJive2`-`(^=)5w7QzM9I~$1{Z!?}x#SZNM(GIXkXGNeuihHs zVXo0pgLP2hRU0ct{iDtK3)^FbPGB_EG)ilW=k&C_n3iLbvWoMV+q#2HnwX;)&MO(AsQ9 zis$>1qylC^r`mzCE2^f+*PiTeOBVT5n>Px&BD>QjGwLT_+DQbd&TA5ELXFmRG?dNI zxE+_)Zud=n*m8jt_&V>s)T#GeT=6HJyR+T225t5rkFRsv-I0{~a%t>ryz4cQvdgP@rkFAnx4Xmsiq%Mvhx9a) zRdh&mqu}Q4-jlry`ooysrqHnC-Wxt@7KIq*W=CIM{|-Ji zNN={YpQ5?hIuPh{4O}qan{K$tO6IHg$A5PBQw=}=`pPZA1%o=UqCAmX8fosx_p{f$ zkz*gIUNe*|vN=0kC4FG)*jSy^6g2TG@T9=DfzEn5{Pc(4rM+`eqfmKg!%&{&!!0DW$ISELS0gE{ z+_T?auH?kl+NE!I8nK>8mxc+oZ~W+O;+k=^_RQeuSc2<>%Q-iRX|mNUyWh=lzxtMZ zYJAy+S09YO>YEp3joAgy%U+c8W?g$5?%WplllN>Hc_10vJKE*#Xa&efa?WQYe$UuP^Ya^pQ{tNSZ_I4J>&UZIHR^7Qxi@&dxcl@Y zOW1)*HsQI^oEpiYt~nv?-Y0=wXK2kQf?A603%QwxcXEf^`z*@uH-22W)4wP{lr2|7 z_hiM*hIIv00s+|yUAt;i7S$$K2wgC3GP)(YJPr?0yD2r_boHw$w^M80c01tG=i(n< zV-xT-MQ=%(%Lo<(zRyQL!|o)23O#$zRUQ~(t}Cd6%lFf^xKj(_;DRAB3zw;BM?A1h zVF>xn{Df0LYT_U81Kh=>RlE&%Ye8IG6nvbsJ58x$nBxOnp>RMDCzM>n4f@U}4 zvT#HmjxnjDm~~ciHK+=CnDk~nB$w#_TR*N??D{#-x)kws!Hr;|8BE4eMq-T83P!}L zK8A|Qk$0G*g0$!K97w#Tk1>|d=_&>13buhc5I6z>^9*9WA%;jYGQvss}Ura zk0kL;3D7Z`?Mm$20!U1uK}8E;Kp3>NutgbBoB{My`!5<>O3f6NGEN=0i;0gum2fS(I5O~o4MvQ(dVaU0|=-vG5pO>iK7?o?zTF=9JpE{i74cwn#qpH)qH zAp=Q#PXe^$V}VIEG|a0MefC@eiT%DX;XarlOZF+)o7biIeB>AIM5uK`(dLiTIcU+o=NjP*}PJS~lEDLe`O+Mu3Wn zK*%;z7cU9krGi9`q!Xa$nVZlF4fw1I;6h?vQb2Wbb(GT@SQ)UkDzePIStxrfsU%y0 za=WUxLii3voXmkW$v^_htN=yqU5P#?Bhi@xc$N&r68%cy2sK~_9;KlZ9;fk8l6$eA=$V%TLh|I2iSW{%^7ndF@ zQa max_file_size: + return { + "success": False, + "response": create_api_response( + code="400", + message=f"文件大小超过 {max_file_size // (1024 * 1024)}MB 限制" + ) + } + + # 3. 权限和已有文件检查 + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + + # 检查会议是否存在及权限 + cursor.execute("SELECT user_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) + meeting = cursor.fetchone() + if not meeting: + return { + "success": False, + "response": create_api_response(code="404", message="会议不存在") + } + if meeting['user_id'] != current_user['user_id']: + return { + "success": False, + "response": create_api_response(code="403", message="无权限操作此会议") + } + + # 检查已有音频文件 + cursor.execute( + "SELECT file_name, file_path, upload_time FROM audio_files WHERE meeting_id = %s", + (meeting_id,) + ) + existing_info = cursor.fetchone() + + # 检查是否有转录记录 + has_transcription = False + if existing_info: + cursor.execute( + "SELECT COUNT(*) as segment_count FROM transcript_segments WHERE meeting_id = %s", + (meeting_id,) + ) + has_transcription = cursor.fetchone()['segment_count'] > 0 + + cursor.close() + except Exception as e: + return { + "success": False, + "response": create_api_response(code="500", message=f"检查已有文件失败: {str(e)}") + } + + # 4. 如果已有转录记录且未确认替换,返回提示 + if existing_info and has_transcription and not force_replace_bool: + return { + "success": False, + "needs_confirmation": True, + "response": create_api_response( + code="300", + message="该会议已有音频文件和转录记录,重新上传将删除现有的转录内容和会议总结", + data={ + "requires_confirmation": True, + "existing_file": { + "file_name": existing_info['file_name'], + "upload_time": existing_info['upload_time'].isoformat() if existing_info['upload_time'] else None + } + } + ) + } + + # 5. 保存音频文件 + meeting_dir = AUDIO_DIR / str(meeting_id) + meeting_dir.mkdir(exist_ok=True) + unique_filename = f"{uuid.uuid4()}{file_extension}" + absolute_path = meeting_dir / unique_filename + relative_path = absolute_path.relative_to(BASE_DIR) + + try: + with open(absolute_path, "wb") as buffer: + shutil.copyfileobj(audio_file.file, buffer) + except Exception as e: + return { + "success": False, + "response": create_api_response(code="500", message=f"保存文件失败: {str(e)}") + } + + transcription_task_id = None + replaced_existing = existing_info is not None + + try: + # 6. 更新数据库记录 + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + + # 删除旧的音频文件 + if replaced_existing and force_replace_bool: + if existing_info and existing_info['file_path']: + old_file_path = BASE_DIR / existing_info['file_path'].lstrip('/') + if old_file_path.exists(): + try: + os.remove(old_file_path) + print(f"Deleted old audio file: {old_file_path}") + except Exception as e: + print(f"Warning: Failed to delete old file {old_file_path}: {e}") + + # 更新或插入音频文件记录 + if replaced_existing: + cursor.execute( + 'UPDATE audio_files SET file_name = %s, file_path = %s, file_size = %s, upload_time = NOW(), task_id = NULL WHERE meeting_id = %s', + (audio_file.filename, '/' + str(relative_path), audio_file.size, meeting_id) + ) + else: + cursor.execute( + 'INSERT INTO audio_files (meeting_id, file_name, file_path, file_size, upload_time) VALUES (%s, %s, %s, %s, NOW())', + (meeting_id, audio_file.filename, '/' + str(relative_path), audio_file.size) + ) + + connection.commit() + cursor.close() + + # 7. 启动转录任务 + try: + transcription_task_id = transcription_service.start_transcription(meeting_id, '/' + str(relative_path)) + print(f"Transcription task {transcription_task_id} started for meeting {meeting_id}") + except Exception as e: + print(f"Failed to start transcription: {e}") + raise + + except Exception as e: + # 出错时清理已上传的文件 + if os.path.exists(absolute_path): + os.remove(absolute_path) + return { + "success": False, + "response": create_api_response(code="500", message=f"处理失败: {str(e)}") + } + + # 8. 返回成功结果 + return { + "success": True, + "file_info": { + "file_name": audio_file.filename, + "file_path": '/' + str(relative_path), + "file_size": audio_file.size + }, + "transcription_task_id": transcription_task_id, + "replaced_existing": replaced_existing, + "has_transcription": has_transcription + } + def _process_tags(cursor, tag_string: Optional[str], creator_id: Optional[int] = None) -> List[Tag]: """ 处理标签:查询已存在的标签,如果提供了 creator_id 则创建不存在的标签 @@ -365,86 +556,72 @@ def get_meeting_for_edit(meeting_id: int, current_user: dict = Depends(get_curre return create_api_response(code="200", message="获取会议编辑信息成功", data=meeting_data) @router.post("/meetings/upload-audio") -async def upload_audio(audio_file: UploadFile = File(...), meeting_id: int = Form(...), force_replace: str = Form("false"), current_user: dict = Depends(get_current_user)): +async def upload_audio( + audio_file: UploadFile = File(...), + meeting_id: int = Form(...), + force_replace: str = Form("false"), + auto_summarize: str = Form("true"), + background_tasks: BackgroundTasks = None, + current_user: dict = Depends(get_current_user) +): + """ + 音频文件上传接口 + + 上传音频文件并启动转录任务,可选择是否自动生成总结 + + Args: + audio_file: 音频文件 + meeting_id: 会议ID + force_replace: 是否强制替换("true"/"false") + auto_summarize: 是否自动生成总结("true"/"false",默认"true") + background_tasks: FastAPI后台任务 + current_user: 当前登录用户 + + Returns: + HTTP 300: 需要用户确认(已有转录记录) + HTTP 200: 处理成功,返回任务ID + HTTP 400/403/404/500: 各种错误情况 + """ force_replace_bool = force_replace.lower() in ("true", "1", "yes") - file_extension = os.path.splitext(audio_file.filename)[1].lower() - if file_extension not in ALLOWED_EXTENSIONS: - return create_api_response(code="400", message=f"Unsupported file type. Allowed types: {', '.join(ALLOWED_EXTENSIONS)}") - max_file_size = getattr(config_module, 'MAX_FILE_SIZE', 100 * 1024 * 1024) - if audio_file.size > max_file_size: - return create_api_response(code="400", message=f"File size exceeds {max_file_size // (1024 * 1024)}MB limit") - try: - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - cursor.execute("SELECT user_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) - meeting = cursor.fetchone() - if not meeting: - return create_api_response(code="404", message="Meeting not found") - if meeting['user_id'] != current_user['user_id']: - return create_api_response(code="403", message="Permission denied") - cursor.execute("SELECT file_name, file_path, upload_time FROM audio_files WHERE meeting_id = %s", (meeting_id,)) - existing_info = cursor.fetchone() - has_transcription = False - if existing_info: - cursor.execute("SELECT COUNT(*) as segment_count FROM transcript_segments WHERE meeting_id = %s", (meeting_id,)) - has_transcription = cursor.fetchone()['segment_count'] > 0 - cursor.close() - except Exception as e: - return create_api_response(code="500", message=f"Failed to check existing files: {str(e)}") - if existing_info and has_transcription and not force_replace_bool: - return create_api_response(code="300", message="该会议已有音频文件和转录记录,重新上传将删除现有的转录内容", data={ - "requires_confirmation": True, - "existing_file": { - "file_name": existing_info['file_name'], - "upload_time": existing_info['upload_time'].isoformat() if existing_info['upload_time'] else None - } - }) - meeting_dir = AUDIO_DIR / str(meeting_id) - meeting_dir.mkdir(exist_ok=True) - unique_filename = f"{uuid.uuid4()}{file_extension}" - absolute_path = meeting_dir / unique_filename - relative_path = absolute_path.relative_to(BASE_DIR) - try: - with open(absolute_path, "wb") as buffer: - shutil.copyfileobj(audio_file.file, buffer) - except Exception as e: - return create_api_response(code="500", message=f"Failed to save file: {str(e)}") - task_id = None - replaced_existing = existing_info is not None - try: - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - if replaced_existing and force_replace_bool: - # 只删除旧的音频文件,转录数据由 start_transcription 统一处理 - if existing_info and existing_info['file_path']: - old_file_path = BASE_DIR / existing_info['file_path'].lstrip('/') - if old_file_path.exists(): - try: - os.remove(old_file_path) - print(f"Deleted old audio file: {old_file_path}") - except Exception as e: - print(f"Warning: Failed to delete old file {old_file_path}: {e}") - if replaced_existing: - cursor.execute('UPDATE audio_files SET file_name = %s, file_path = %s, file_size = %s, upload_time = NOW(), task_id = NULL WHERE meeting_id = %s', (audio_file.filename, '/'+str(relative_path), audio_file.size, meeting_id)) - else: - cursor.execute('INSERT INTO audio_files (meeting_id, file_name, file_path, file_size, upload_time) VALUES (%s, %s, %s, %s, NOW())', (meeting_id, audio_file.filename, '/'+str(relative_path), audio_file.size)) - connection.commit() - cursor.close() - try: - # start_transcription 会自动删除旧的转录数据和任务记录 - task_id = transcription_service.start_transcription(meeting_id, '/'+str(relative_path)) - print(f"Transcription task started with ID: {task_id}") - except Exception as e: - print(f"Failed to start transcription task: {e}") - except Exception as e: - if os.path.exists(absolute_path): - os.remove(absolute_path) - return create_api_response(code="500", message=f"Failed to save file info: {str(e)}") - return create_api_response(code="200", message="Audio file uploaded successfully" + (" and replaced existing file" if replaced_existing else ""), data={ - "file_name": audio_file.filename, "file_path": '/'+str(relative_path), "task_id": task_id, - "transcription_started": task_id is not None, "replaced_existing": replaced_existing, - "previous_transcription_cleared": replaced_existing and has_transcription - }) + auto_summarize_bool = auto_summarize.lower() in ("true", "1", "yes") + + # 调用公共处理方法 + result = _handle_audio_upload(audio_file, meeting_id, force_replace_bool, current_user) + + # 如果不成功,直接返回响应 + if not result["success"]: + return result["response"] + + # 成功:根据auto_summarize参数决定是否添加监控任务 + transcription_task_id = result["transcription_task_id"] + if auto_summarize_bool and transcription_task_id: + background_tasks.add_task( + async_meeting_service.monitor_and_auto_summarize, + meeting_id, + transcription_task_id + ) + print(f"[upload-audio] Auto-summarize enabled, monitor task added for meeting {meeting_id}") + message_suffix = ",正在进行转录和总结" + else: + print(f"[upload-audio] Auto-summarize disabled for meeting {meeting_id}") + message_suffix = "" + + # 返回成功响应 + return create_api_response( + code="200", + message="Audio file uploaded successfully" + + (" and replaced existing file" if result["replaced_existing"] else "") + + message_suffix, + data={ + "file_name": result["file_info"]["file_name"], + "file_path": result["file_info"]["file_path"], + "task_id": transcription_task_id, + "transcription_started": transcription_task_id is not None, + "auto_summarize": auto_summarize_bool, + "replaced_existing": result["replaced_existing"], + "previous_transcription_cleared": result["replaced_existing"] and result["has_transcription"] + } + ) @router.get("/meetings/{meeting_id}/audio") def get_audio_file(meeting_id: int, current_user: dict = Depends(get_current_user)): @@ -456,6 +633,115 @@ def get_audio_file(meeting_id: int, current_user: dict = Depends(get_current_use return create_api_response(code="404", message="Audio file not found for this meeting") return create_api_response(code="200", message="Audio file found", data=audio_file) +@router.get("/meetings/{meeting_id}/audio/stream") +async def stream_audio_file( + meeting_id: int, + range: Optional[str] = Header(None, alias="Range") +): + """ + 音频文件流式传输端点,支持HTTP Range请求(Safari浏览器必需) + 无需登录认证,用于前端audio标签直接访问 + """ + # 获取音频文件信息 + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + cursor.execute("SELECT file_name, file_path, file_size FROM audio_files WHERE meeting_id = %s", (meeting_id,)) + audio_file = cursor.fetchone() + if not audio_file: + return Response(content="Audio file not found", status_code=404) + + # 构建完整文件路径 + file_path = BASE_DIR / audio_file['file_path'].lstrip('/') + if not file_path.exists(): + return Response(content="Audio file not found on disk", status_code=404) + + # 总是使用实际文件大小(不依赖数据库记录,防止文件被优化后大小不匹配) + file_size = os.path.getsize(file_path) + file_name = audio_file['file_name'] + + # 根据文件扩展名确定MIME类型 + extension = os.path.splitext(file_name)[1].lower() + mime_types = { + '.mp3': 'audio/mpeg', + '.m4a': 'audio/mp4', + '.wav': 'audio/wav', + '.mpeg': 'audio/mpeg', + '.mp4': 'audio/mp4' + } + content_type = mime_types.get(extension, 'audio/mpeg') + + # 处理Range请求 + start = 0 + end = file_size - 1 + + if range: + # 解析Range头: "bytes=start-end" 或 "bytes=start-" + try: + range_spec = range.replace("bytes=", "") + if "-" in range_spec: + parts = range_spec.split("-") + if parts[0]: + start = int(parts[0]) + if parts[1]: + end = int(parts[1]) + except (ValueError, IndexError): + pass + + # 确保范围有效 + if start >= file_size: + return Response( + content="Range Not Satisfiable", + status_code=416, + headers={"Content-Range": f"bytes */{file_size}"} + ) + + end = min(end, file_size - 1) + content_length = end - start + 1 + + # 对所有文件名统一使用RFC 5987标准的URL编码格式 + # 这样可以正确处理中文、特殊字符等所有情况 + encoded_filename = quote(file_name) + filename_header = f"inline; filename*=UTF-8''{encoded_filename}" + + # 生成器函数用于流式读取文件 + def iter_file(): + with open(file_path, 'rb') as f: + f.seek(start) + remaining = content_length + chunk_size = 64 * 1024 # 64KB chunks + while remaining > 0: + read_size = min(chunk_size, remaining) + data = f.read(read_size) + if not data: + break + remaining -= len(data) + yield data + + # 根据是否有Range请求返回不同的响应 + if range: + return StreamingResponse( + iter_file(), + status_code=206, # Partial Content + media_type=content_type, + headers={ + "Content-Range": f"bytes {start}-{end}/{file_size}", + "Accept-Ranges": "bytes", + "Content-Length": str(content_length), + "Content-Disposition": filename_header + } + ) + else: + return StreamingResponse( + iter_file(), + status_code=200, + media_type=content_type, + headers={ + "Accept-Ranges": "bytes", + "Content-Length": str(file_size), + "Content-Disposition": filename_header + } + ) + @router.get("/meetings/{meeting_id}/transcription/status") def get_meeting_transcription_status(meeting_id: int, current_user: dict = Depends(get_current_user)): try: diff --git a/app/core/config.py b/app/core/config.py index 959e027..bd0949d 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -61,6 +61,12 @@ REDIS_CONFIG = { # Dashscope (Tongyi Qwen) API Key QWEN_API_KEY = os.getenv('QWEN_API_KEY', 'sk-c2bf06ea56b4491ea3d1e37fdb472b8f') +# 转录轮询配置 - 用于 upload-audio-complete 接口 +TRANSCRIPTION_POLL_CONFIG = { + 'poll_interval': int(os.getenv('TRANSCRIPTION_POLL_INTERVAL', '10')), # 轮询间隔:10秒 + 'max_wait_time': int(os.getenv('TRANSCRIPTION_MAX_WAIT_TIME', '1800')), # 最大等待:30分钟 +} + # LLM配置 - 阿里Qwen3大模型 LLM_CONFIG = { 'model_name': os.getenv('LLM_MODEL_NAME', 'qwen-plus'), diff --git a/app/services/async_meeting_service.py b/app/services/async_meeting_service.py index 7fd9b4b..50358e9 100644 --- a/app/services/async_meeting_service.py +++ b/app/services/async_meeting_service.py @@ -8,8 +8,9 @@ from datetime import datetime from typing import Optional, Dict, Any, List import redis -from app.core.config import REDIS_CONFIG +from app.core.config import REDIS_CONFIG, TRANSCRIPTION_POLL_CONFIG from app.core.database import get_db_connection +from app.services.async_transcription_service import AsyncTranscriptionService from app.services.llm_service import LLMService class AsyncMeetingService: @@ -110,6 +111,93 @@ class AsyncMeetingService: self._update_task_in_db(task_id, 'failed', 0, error_message=error_msg) self._update_task_status_in_redis(task_id, 'failed', 0, error_message=error_msg) + def monitor_and_auto_summarize(self, meeting_id: int, transcription_task_id: str): + """ + 监控转录任务,完成后自动生成总结 + 此方法设计为由BackgroundTasks调用,在后台运行 + + Args: + meeting_id: 会议ID + transcription_task_id: 转录任务ID + + 流程: + 1. 循环轮询转录任务状态 + 2. 转录成功后自动启动总结任务 + 3. 转录失败或超时则停止轮询并记录日志 + """ + print(f"[Monitor] Started monitoring transcription task {transcription_task_id} for meeting {meeting_id}") + + # 获取配置参数 + poll_interval = TRANSCRIPTION_POLL_CONFIG['poll_interval'] + max_wait_time = TRANSCRIPTION_POLL_CONFIG['max_wait_time'] + max_polls = max_wait_time // poll_interval + + # 延迟导入以避免循环导入 + transcription_service = AsyncTranscriptionService() + + poll_count = 0 + + try: + while poll_count < max_polls: + poll_count += 1 + elapsed_time = poll_count * poll_interval + + try: + # 查询转录任务状态 + status_info = transcription_service.get_task_status(transcription_task_id) + current_status = status_info.get('status', 'unknown') + progress = status_info.get('progress', 0) + + print(f"[Monitor] Poll {poll_count}/{max_polls} - Status: {current_status}, Progress: {progress}%, Elapsed: {elapsed_time}s") + + # 检查转录是否完成 + if current_status == 'completed': + print(f"[Monitor] Transcription completed successfully for meeting {meeting_id}") + + # 启动总结任务 + try: + summary_task_id = self.start_summary_generation(meeting_id, user_prompt="") + print(f"[Monitor] Summary task {summary_task_id} started for meeting {meeting_id}") + + # 在后台执行总结任务 + self._process_task(summary_task_id) + + except Exception as e: + error_msg = f"Failed to start summary generation: {e}" + print(f"[Monitor] {error_msg}") + + # 监控任务完成,退出循环 + break + + # 检查转录是否失败 + elif current_status == 'failed': + error_msg = status_info.get('error_message', 'Unknown error') + print(f"[Monitor] Transcription failed for meeting {meeting_id}: {error_msg}") + # 转录失败,停止监控 + break + + # 转录还在进行中(pending/processing),继续等待 + elif current_status in ['pending', 'processing']: + # 等待一段时间后继续轮询 + time.sleep(poll_interval) + + else: + # 未知状态 + print(f"[Monitor] Unknown transcription status: {current_status}") + time.sleep(poll_interval) + + except Exception as e: + print(f"[Monitor] Error checking transcription status: {e}") + # 出错后等待一段时间继续尝试 + time.sleep(poll_interval) + + # 检查是否超时 + if poll_count >= max_polls: + print(f"[Monitor] Transcription monitoring timed out after {max_wait_time}s for meeting {meeting_id}") + + except Exception as e: + print(f"[Monitor] Fatal error in monitor_and_auto_summarize: {e}") + # --- 会议相关方法 --- def _get_meeting_transcript(self, meeting_id: int) -> str: diff --git a/test/test_upload_audio_complete.py b/test/test_upload_audio_complete.py new file mode 100644 index 0000000..d716058 --- /dev/null +++ b/test/test_upload_audio_complete.py @@ -0,0 +1,275 @@ +""" +测试 upload_audio 接口的 auto_summarize 参数 +""" +import requests +import time +import json + +# 配置 +BASE_URL = "http://localhost:8000/api" +# 请替换为你的有效token +AUTH_TOKEN = "your_auth_token_here" +# 请替换为你的测试会议ID +TEST_MEETING_ID = 1 + +# 请求头 +headers = { + "Authorization": f"Bearer {AUTH_TOKEN}" +} + + +def test_upload_audio(auto_summarize=True): + """测试音频上传接口""" + print("=" * 60) + print(f"测试: upload_audio 接口 (auto_summarize={auto_summarize})") + print("=" * 60) + + # 准备测试文件 + audio_file_path = "test_audio.mp3" # 请替换为实际的音频文件路径 + + try: + with open(audio_file_path, 'rb') as audio_file: + files = { + 'audio_file': ('test_audio.mp3', audio_file, 'audio/mpeg') + } + data = { + 'force_replace': 'false', + 'auto_summarize': 'true' if auto_summarize else 'false' + } + + # 发送请求 + url = f"{BASE_URL}/meetings/upload-audio" + print(f"\n发送请求到: {url}") + print(f"参数: auto_summarize={data['auto_summarize']}") + response = requests.post(url, headers=headers, files=files, data=data) + + print(f"状态码: {response.status_code}") + print(f"响应内容:") + print(json.dumps(response.json(), indent=2, ensure_ascii=False)) + + # 如果上传成功,获取任务ID + if response.status_code == 200: + response_data = response.json() + if response_data.get('code') == '200': + task_id = response_data['data'].get('task_id') + auto_sum = response_data['data'].get('auto_summarize') + print(f"\n✓ 上传成功! 转录任务ID: {task_id}") + print(f" 自动总结: {'开启' if auto_sum else '关闭'}") + if auto_sum: + print(f" 提示: 音频已上传,后台正在自动进行转录和总结") + else: + print(f" 提示: 音频已上传,正在进行转录(不会自动总结)") + print(f"\n 可以通过以下接口查询状态:") + print(f" - 转录状态: GET /meetings/{TEST_MEETING_ID}/transcription/status") + print(f" - 总结任务: GET /meetings/{TEST_MEETING_ID}/llm-tasks") + print(f" - 会议详情: GET /meetings/{TEST_MEETING_ID}") + return True + elif response_data.get('code') == '300': + print("\n⚠ 需要确认替换现有文件") + return False + else: + print(f"\n✗ 上传失败") + return False + + except FileNotFoundError: + print(f"\n✗ 错误: 找不到测试音频文件 {audio_file_path}") + print("请创建一个测试音频文件或修改 audio_file_path 变量") + return False + except Exception as e: + print(f"\n✗ 错误: {e}") + return False + + +def test_get_transcription_status(): + """测试获取转录状态接口""" + print("\n" + "=" * 60) + print("测试: 获取转录状态") + print("=" * 60) + + url = f"{BASE_URL}/meetings/{TEST_MEETING_ID}/transcription/status" + print(f"\n发送请求到: {url}") + + try: + response = requests.get(url, headers=headers) + print(f"状态码: {response.status_code}") + print(f"响应内容:") + print(json.dumps(response.json(), indent=2, ensure_ascii=False)) + + if response.status_code == 200: + response_data = response.json() + if response_data.get('code') == '200': + data = response_data['data'] + print(f"\n✓ 获取转录状态成功!") + print(f" - 任务ID: {data.get('task_id')}") + print(f" - 状态: {data.get('status')}") + print(f" - 进度: {data.get('progress')}%") + return data.get('status'), data.get('progress') + else: + print(f"\n✗ 获取状态失败") + return None, None + + except Exception as e: + print(f"\n✗ 错误: {e}") + return None, None + + +def test_get_llm_tasks(): + """测试获取LLM任务列表""" + print("\n" + "=" * 60) + print("测试: 获取LLM任务列表") + print("=" * 60) + + url = f"{BASE_URL}/meetings/{TEST_MEETING_ID}/llm-tasks" + print(f"\n发送请求到: {url}") + + try: + response = requests.get(url, headers=headers) + print(f"状态码: {response.status_code}") + print(f"响应内容:") + print(json.dumps(response.json(), indent=2, ensure_ascii=False)) + + if response.status_code == 200: + response_data = response.json() + if response_data.get('code') == '200': + tasks = response_data['data'].get('tasks', []) + print(f"\n✓ 获取LLM任务成功! 共 {len(tasks)} 个任务") + if tasks: + latest_task = tasks[0] + print(f" 最新任务:") + print(f" - 任务ID: {latest_task.get('task_id')}") + print(f" - 状态: {latest_task.get('status')}") + print(f" - 进度: {latest_task.get('progress')}%") + return latest_task.get('status'), latest_task.get('progress') + return None, None + else: + print(f"\n✗ 获取任务失败") + return None, None + + except Exception as e: + print(f"\n✗ 错误: {e}") + return None, None + + +def monitor_progress(): + """持续监控处理进度""" + print("\n" + "=" * 60) + print("持续监控处理进度 (每10秒查询一次)") + print("按 Ctrl+C 停止监控") + print("=" * 60) + + try: + transcription_completed = False + summary_completed = False + + while True: + print(f"\n[{time.strftime('%H:%M:%S')}] 查询状态...") + + # 查询转录状态 + trans_status, trans_progress = test_get_transcription_status() + + # 如果转录完成,查询总结状态 + if trans_status == 'completed' and not transcription_completed: + print(f"\n✓ 转录已完成!") + transcription_completed = True + + if transcription_completed: + summ_status, summ_progress = test_get_llm_tasks() + if summ_status == 'completed' and not summary_completed: + print(f"\n✓ 总结已完成!") + summary_completed = True + break + elif summ_status == 'failed': + print(f"\n✗ 总结失败") + break + + # 检查转录是否失败 + if trans_status == 'failed': + print(f"\n✗ 转录失败") + break + + # 如果全部完成,退出 + if transcription_completed and summary_completed: + print(f"\n✓ 全部完成!") + break + + time.sleep(10) + + except KeyboardInterrupt: + print("\n\n⚠ 用户中断监控") + except Exception as e: + print(f"\n✗ 监控出错: {e}") + + +def main(): + """主函数""" + print("\n") + print("╔" + "═" * 58 + "╗") + print("║" + " " * 12 + "upload_audio 接口测试" + " " * 23 + "║") + print("║" + " " * 10 + "(测试 auto_summarize 参数)" + " " * 17 + "║") + print("╚" + "═" * 58 + "╝") + + print("\n请确保:") + print("1. 后端服务正在运行 (http://localhost:8000)") + print("2. 已修改脚本中的 AUTH_TOKEN 和 TEST_MEETING_ID") + print("3. 已准备好测试音频文件") + + input("\n按回车键开始测试...") + + # 测试1: 查看当前转录状态 + test_get_transcription_status() + + # 测试2: 查看当前LLM任务 + test_get_llm_tasks() + + # 询问要测试哪种模式 + print("\n" + "-" * 60) + print("请选择测试模式:") + print("1. 仅转录 (auto_summarize=false)") + print("2. 转录+自动总结 (auto_summarize=true)") + print("3. 两种模式都测试") + choice = input("请输入选项 (1/2/3): ") + + if choice == '1': + # 测试:仅转录 + if test_upload_audio(auto_summarize=False): + print("\n⚠ 注意: 此模式下不会自动生成总结") + print("如需生成总结,请手动调用: POST /meetings/{meeting_id}/generate-summary-async") + elif choice == '2': + # 测试:转录+自动总结 + if test_upload_audio(auto_summarize=True): + print("\n" + "-" * 60) + choice = input("是否要持续监控处理进度? (y/n): ") + if choice.lower() == 'y': + monitor_progress() + elif choice == '3': + # 两种模式都测试 + print("\n" + "=" * 60) + print("测试模式1: 仅转录 (auto_summarize=false)") + print("=" * 60) + test_upload_audio(auto_summarize=False) + + input("\n按回车键继续测试模式2...") + + print("\n" + "=" * 60) + print("测试模式2: 转录+自动总结 (auto_summarize=true)") + print("=" * 60) + if test_upload_audio(auto_summarize=True): + print("\n" + "-" * 60) + choice = input("是否要持续监控处理进度? (y/n): ") + if choice.lower() == 'y': + monitor_progress() + else: + print("\n✗ 无效选项") + + print("\n" + "=" * 60) + print("测试完成!") + print("=" * 60) + print("\n总结:") + print("- auto_summarize=false: 只执行转录,不自动生成总结") + print("- auto_summarize=true: 执行转录后自动生成总结") + print("- 默认值: true (向前兼容)") + print("- 现有页面建议设置: auto_summarize=false") + + +if __name__ == "__main__": + main() diff --git a/test_voiceprint_api.py b/test/test_voiceprint_api.py similarity index 100% rename from test_voiceprint_api.py rename to test/test_voiceprint_api.py