From 4b1b8a358621737edef102ec4c6a5c8160289079 Mon Sep 17 00:00:00 2001 From: manoraga Date: Tue, 18 Aug 2026 13:47:26 +0300 Subject: [PATCH] =?UTF-8?q?feat(llm):=20=D0=B4=D0=B2=D1=83=D1=85=D1=84?= =?UTF-8?q?=D0=B0=D0=B7=D0=BD=D1=8B=D0=B9=20=D0=B6=D0=B8=D0=B7=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=B9=20=D1=86=D0=B8=D0=BA=D0=BB=20=D1=81?= =?UTF-8?q?=D0=B5=D1=81=D1=81=D0=B8=D0=B9,=20=D0=B4=D0=B2=D1=83=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=BE=D0=BD=D0=BD=D0=B8=D0=B9=20Diff,=20Topi?= =?UTF-8?q?c=20Drift=20Guard=20=D0=B8=20=D0=B0=D0=BA=D1=82=D1=83=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B4=D0=BE=D0=BA?= =?UTF-8?q?=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/scud_orion_ai.db | Bin 6062080 -> 6520832 bytes data/scud_orion_ai.db-shm | Bin 0 -> 32768 bytes data/scud_orion_ai.db-wal | Bin 0 -> 185432 bytes docs/CHANGELOG.md | 43 +- docs/development_roadmap.md | 97 - .../план_оптимизации_работы_с_контекстом.md | 69 +- modules/web_api/llm/agent.py | 239 +- modules/web_api/llm/core/fast_path.py | 99 +- modules/web_api/llm/core/tool_injector.py | 23 +- modules/web_api/llm/db/db_prompts.py | 43 +- modules/web_api/llm/schemas.py | 69 +- modules/web_api/routers/chat.py | 9 +- .../web_api/scripts/diagnostics/inspect_db.py | 47 - .../scripts/diagnostics/inspect_files.py | 23 - .../scripts/diagnostics/make_code_snapshot.py | 31 - modules/web_api/static/js/chat.js | 568 ----- modules/web_api/static/js/chat/core.js | 170 +- project_code_snapshot.md | 2139 +++++++++-------- scripts/db_cli.py | 69 +- scripts/diagnostics/make_code_snapshot.py | 31 +- 20 files changed, 1763 insertions(+), 2006 deletions(-) create mode 100644 data/scud_orion_ai.db-shm create mode 100644 data/scud_orion_ai.db-wal delete mode 100644 docs/development_roadmap.md delete mode 100644 modules/web_api/scripts/diagnostics/inspect_db.py delete mode 100644 modules/web_api/scripts/diagnostics/inspect_files.py delete mode 100644 modules/web_api/scripts/diagnostics/make_code_snapshot.py delete mode 100644 modules/web_api/static/js/chat.js diff --git a/data/scud_orion_ai.db b/data/scud_orion_ai.db index df51d11d7c6b7567645845999fa748d8c08f5d89..7900e5b67b1cf5d637d56bf6727b0afa5a603e01 100644 GIT binary patch delta 192977 zcmaHU30zgh7yq5P>$~s0dqEIo6Ht^*Wd{*ZKtsa~Fio=sOwBA6)K*gv*USanMsWkp zC09)4npT2qnhR=~TMD8e?>$SctSt3^?j4tY@ALov`Q-b~`Oe&ZmNVy^nF%CbP~*a*;cd~(g}1|7NLS01( zo-I$6Kat;)$H;HVIr3mRTka)ymy_iTsoyGVNz;LjqhKfwJWtsB4}MOrk#m?EtWz~my$jVWOGNktk9;)g|*`hlMmRSpA~ zSXB8Ez{f?E-2px-s*C}6uc)#uzz0Q@9sm=HDn)?tMY}%$a4FjT7Qnco-Gcz~i+1+} z$Sc|nUyL7Hv^yA}plEkX0JUhh#4v1JQ3X8GLchchcpO^vW$w=9iEndKuYU+H`O9;bfa0e%a=*Kcjj zad?G%(eaTC!@YGh8`2)f2Ewn~u`cl2{CF1px*vOYVRpYSy-ezH{fd$51NC?H7xk8U zT|KW}QESxW&#O(b$ngo&@y?nzVyaMQ!uz8E0_%yd@WEp&Yj`raxZc`oQYuw z3P7E;!~V#DFvpXMqVOI4Rc77K>iBIQM?gCeizr+)9cWg!H(P}gj z#UYM4!>lqMgkj>?(QHj|&AuA_=IRlt-5Mp3p8`=5c8JE!;id}|_nx6Q+anM)L!??m zvD(=^=mL*zcW8mejT9S7DBk?YKyq>i>W=HYC|QdrsQ{^M`W6kOvi8+Z{jB=iz*R3& z{07aaLX}mR^Ow5F4!B>THL=v&-5EFa6@9*(U@et zpB5o>?g3SzFR6=uAwgJL4e1P$e<(d3FAA=VU{B8Xi++0bzz1;7A^H^~;fnNu+L(!B zKig7rQ7WLN2|Te>v0j35SgBACY3-}nX-ZdkjQ5?MZN*F=n>mrop>eROP7l1?LVi8P zUU2>Ee;-t|k_)HV_H4FXN5mR-nZ+Dw4lui#k$hLKd(xOvs<;-Ih9OhL_3 zhN~^fpiHqNR%a9c;;e3@>~%3-ld{BZ7EX0%IC3Uaj3;+r6DJrcZ2O0t&J+{KPp^w9 zh~{EPGn&^_Pwu`XCg20p4b&dJA+lA`D1=F3?lV%2v`iW*g^9n5--t8Bequ|ZPS_-j z7m}c-dW(I?pX1Bw8hG^QKq}qd9ap z_^Qo7ElWeqjhl=lfBQ?(9Myk1gu&0kFliaSvX5Rbt~D?${GjvXOn?-nwQmo( zG%q1g8c9>TT1&As^+o`cZ?FI4z`5|!P=hX5bX$fBkbqk<>H&4C+Fj+9Bg#yrr(&`m zw|-{rZM9lXS>{_ZEw1LX=7r{$%+2NVGLZ+#o~BEt<)&9nzS31`wUi?Th}WUF9wxRG z8iWnPNFjvZ%-e8Fek>o&-RHjIKH<7@m_5wSVtcU)K7$wGm*K)%Ocl2rkvrGjc(OiF z>VzFK$&Zv0wO>0+16ka3nUn`g$=LQA#m3DP8x|ReZjveCQWsp`hthSv6l*z(R~-gw zmxHB!$Vd_#EQ<`ZC0w#yXL`WAN~d2lXGzUxdq&Ct`lcwud+MXY3$2v zD1L}*@LKc$zc0O^EL27)35vyf&HANvx^<8>%<{-`#^V0DMbzQ#M@ny$bI4R0rz!^4zh(-ocQ`=Ut_6 zdilmJ>PN(NNVjC;K>p?=xKUD4*ddOj^FLlICLS%vmDM zf9Y**9UX#?PBkTIqg_q16|3z@DjVXy1e~Nbdaf&PJxT z$j_9QVx&N^R$L(t65WLwVGh@YtrmK+-Ejs!icXSNg{Evz?26~1MW&WeH`hud z(N}0P>V_odB%|d^0X(S9uZtqNHQN+{9WJDLswoXSR8lwF6px$gsN7UbW!v9Ww*6(u zTG~|8eoF_wJ=2}p!f@R=6MKqX$PQwC@pYw6S+7JW@2EaF7U!XZ>dV|+u7KG>LW@oP zJO=Qk>M_&`JyI8-Ie0VE$nE9dQ zdCXODler|0V^6>Xz5@N}ExaASkCQQnzDEntVC2sw+oPx=_6OC&epA4J5a4 za2naAz)yq{JWac=1k1?C(L&i^32A%9U@w*yjpCrxdMJ-ezM;$yi%kDuhdX_B(@_%Ms}^N-1Li8A6B6kf*j7#LT|SEOL2Hqy2!{yM4vL4CIQ`b0_=ABp?L zLNQgu!a-r0@PZ)lNBLR&i@eNz&&}cba0=?kY*wq;IqZupkE`({oPhp7J2*6&vFXnl zMDmhT-ARg9MhLbyCw0r*yOT(_jAY#OF9`^c)3j3e41bK(B?KNl5!>C3ToTwSqbqKx zq(yJ8q*y;j$F5YU4Wf9>O7Y6i29llLaOB0slsw;u;yIpXhPguKiT5aZtOLa(4=Elx zX`uF}N5&2oFUvI~a!)_sVwDOGFSz_J*NlJZCu({?(Gfzi@i@hX-4t&wH;_#9&4?B1 zlAywbIlmJ}SY@WnMYwljmNVj1vAf6%M}(O|Pr;;C6PH5OgS0M`Jp}$Z{~6z#bSjiP zY9AKLZWwc-Hn&Lr7;!3xUeI-$RHVOZkzDvpj>C>{a^^ER8QVQ6+8Bxz%_(keGLX#b zBR?X!v*kqX;Y?W;No3WKiO)}??Vm5Fxp?BCOfs{PQBSL@q?e>Nf|oiP^+WrZdg5Lt zXA8(36*3#ih%z~j_H$eOlRe0tb#g20NFvq5Jsdj{N%eX;0o$xpwpplbZ%1W&TSF#o z*T~77;DT1_547ybeDg?tYK+$DbNNIfm%)@V#O2O_)+EIxyrq|Ep6O+iztkjsDNU5R zOIGo`xLO>^l!96ug zQn$iA+7PvW1o?YJ-sI(ITsgy_@bnVJ?UipaDS2}?#rh!>>%u73x>3CPfZ~acQ6srmi)I7(hK4eMWhpF#idysJ`wkZ@FuNt`IL+C+nZa328Tpo0*Z~>@! za20&bkK&bIDPF!n@!|#p$>9&+*mEr?dHS@0TGmIYbMQ>$VupVf_-ECSi^66Wq(L>b z)>xNYM_3~*|5}b(7Fb?GvyeN^Q*JZYl#dkN;%BZiuQ9)A4wP@o>%rOwv0d^|e9leO|;Yekh z+FI$%l(8zlgiY(vH(*9wqUM%B*J^0iv$|w}W}QlMRWm48t_G5th>Z8O{K$-n7VJnN zJ7S>MwTICn_E19xJuf4k=GdB%+^!j`EAteTCn}wo7npw-(lST!5!9o~7A1+q!uENZ~~-X$hN0uK!pA_4VsvbL^HuCgjkHt4i1@Xz?8M>#xcNjq1Wsd5o8 zi=HnK=}wl9U}?1GDho(NRj3!~awp3}uxzphq8Rehovd~y^;6~{bA*}CkR6BIVoA57 zZh0jCZdM|8yr=;tfR?UHHZPJ}rvSoLblDEQhc4Tr44GVfnDrH996c!`Kh7#J&WW`X zr0Q{2_YGXJ>LR_ukAj=#9mGjZ7UPI(3MQ z_m7P49FfpDI+AH>fCG>IUvncmF^*EmJ^cU7H9_v4|H+Ns>a6#B!BP7dDaMK7#4l zO6gN+uoNu*DIO6Q!>lzLRG2eDnJ`x9B3St=Fn9eJ)R>lBBPn!=^&tK(Q7*x3qI#O^ z$zEdLHQzJuF;6n5nML^o*v$vZ-lkg9I@2gqxb(a96`5A!5~hKapt9YY%62a*+hF<*gDq!6`os!_gww;doklk4Hy_3d{Y2WA(_|Nx)SY)p z(C%GuQCoyDKQmonK4Mv78E%QOurO6G2feMUSy7ijlbEV_T8>(`Gh1NlzKXOfi|ZbU zhN!P8yOa!Tt$Nh@K2wiR;bNSHo1t@Pku~1(m)Z*Xl%%%QZ-6J;n#G0+j=so;5r}_z zTmt9FM2qN55GGPQ*}F9^N~n9&@)z?Bs0iFwwA0yI2zKwhPt{;;z?!&`GH>%!TQO9H zcy<)FcP5eUvEkUBK<=)Ki`N`m<3zB*&X4S>&(Y|q$)fFX(MC451?+N#4hWBDlF%rI zsM>KJrlOB!jz7%cH)@P-sX@{q#Cs~3}HzoxM49u;TD z7R?j$U6)QA+m-PxN%efj#NqlZXlU@Fg=;OmY^c- z#Xns3nDnmMs07BBSal#hip)KlzD1HnU#M+gF~p}TJv|<6#cjlY%%7V3m|f&^@)GbH z`IxSm)|iHyf+ahQX8E94MT24QE7X8r#b?>+Y)|$diIhaNjiC=bVy}ajt8!w&lC@m&6%mu9aoYP@!neWm1_X42dZ7 z@KD5m(Z5mx+OHl&MNq$!3SH9!Q4eGR&%+XRmfA=8Q7Kj4P$Cu9`UCyk^q06!b4|cE z!9=KI{Yr{;Fh|qn+JW@h?A;919!zn4%dEfu@%jfHOLF<74&-f962XpF=n?kr6m2kM z>u=35n97YX`_s?ZaFg=3(8>rmR4l52FlMg>^|)+c>ygM4Wva)_-OMKWl)Mm(dA_FW zrVXZdO_9=lnAsIdFTh7WEiMAPS1X}j*eHw^qWC}gulXt9dNFe~++yw(&X2vpe!-3b zcjI698(f5YU<-4VS*ymNTC@wzR9C2D>7eRg?G}UWv1CV;8|e5xbTG1cQ?$pB>YqA< zk*i1D7UD(+E!1$H;>~X;*3Y9@_d3Zx;Fd^*YaMi?0@?DNTOz)=o93J!Z6L`9QfvIq zZ8mSnOAfg;;6^*WZo`ihZ+=a&elEqj*C^JeQ@qw*g8{i6)<1>|QLEivzz(qD!Y$*H zI~3>bqD37Ul{Idh3rZk-ty_SAN8&(KOWM`C^&=B&-R5$53v)s5AAg{Ru=5T#@I?(H zH4e8lZ0}>BV2g$8z#KRSrD&kKHQQQnj7ebTz<_`})jjGYHBot>98-!71MNxcJZpce zhvl;6b4#uz(A>z(F|RfcGq;g%$Q$H$nbUGxlf(46Dc96W`WcKp!zEwwD$Ie#h~dHm zVV^J!H5a-IX8sgk!oS43flF-zH-<}K@3UW_A?#$f2P_qg6=2bTX-v6$ zyk)HgYP_J=Y#utzj85_D-h1dJ1_pQIk84S$2<8N9Z6X{5o>utd{Ymcoof3ruhlNRK zvj(r>3xwIL1U+_%hIh-(B6rUx#|n$VSBNtLPQ%x4;eFc0r9V<>O zC6SBW;{_YIp26U#e+yxe4_xDK<2wikzJ?op$Iya=V;R8zYG{qZvQOYPx*M9Kd<>ja z+~@IZJ|?N*e3Y0_2IZJWFmz@_3suuV4QXkp!q(Gg<&jSwf!RT)9LG|~Wa6HTmyILe z5cl^ne}H7Y85~A(mb!D8dz;*SD>y-@fX*JzB$4A?!3zc|2?CWB1{L909Na{eG0+hI zWpDsejDcL(46ZWf3qt`A8qdJptuf}{LfP+-b|-a#ahh5CMwo@Q8{CCKP$m_*kUNAuQmq~jtlSs@8X`M7$iXq)=y9Ksl|7NS$nV>E-$5+5fGzv$eztDa( z1NA}f%w;}Tc>4u=;?&?m~Na9VT zvcett7YNP4limhQfoKnx$|>wQ&@OVp$8a6`hWFHP<#**+BDOQ)pIpud-(zn=r%HV@$^p;XAmBXAr3 zEWeU}m2b=0IE^d968b?v%g|8JFXokHd+Q4BG(@IebMGoFfY=1|CCT;lhS?GXXyAF| z%=zGWL62YH4w9Nhu4zIYDDMKyo``FtaDFQAqsf$ouCaLDQF3Mh)VpmSP#>#Fq%|p4 zsD(fb1Tz~L^@Mo`APut~7?+_|^QWQac!vvz)1mZDa^_|44#J{*C{anOdv=Kx&L4y_ z!Nhe!cBHTvA}R1B5^y*uQK;JiWpz+S_o=1`_alT!OF`%7H>#65BCWr~n>sCAkMf6NMG`Aia^~jtfl?ZvF;O z<45W~3I&stow%NHPnNdDLz`gGRuXW)D^|Fr_mi7R?HaEPd~7mtx#ZpxqfI2gzgMzQ z4}B#9lRc#I;z6Y5jC(4)3&EFEy9IX_)Ac^T3^DGmU-Mn1TFGQpqeJgaoFCElsCA6 zu>-tn?V^RCWwEWbN4J9-a2-gIG75{oK6B36hW0A5SM8i(hF?de`_x zNcb0Ul4RqrpL7?7D|!CXSK4F8+r|?Kj*-uvlq?QZEAu>&zW^^H32z``#NZcV zq&V@Rcu4$A%oaU`T5$W05#snq{9%3$-=Fv5>bQ;ESS}6>E{E99*ld`b)#45M+X=#k z=DnoEGfLY=Jd0s2+i5TufM!#)kE3WCVjvm4($hq4bs8K&u02iRoBz-<^(QFSt)N&t z%|LB^r@^m?ii}d36sD9>11+u1O++frXeTH=EyXYxbu7-vRunhO5an029!-FPj8TcP zBj2dlL@3Ef&`vo(D$kr{UDV~iP9Ln55D04 z$A^PG&`)S7IQzU{U|j)MV8DhlN&kj^Pl6@<$~~cmk0)m~dq(1U7fEVK#&PU`F9@9j zw!;_H(bkRPt!73CqUEml9LE}mARDRK>=}jauNorhRqi>Go~wR0WhSlhjHS%AmkiY6 zH+df8+ktk-q%-9V#3ap7UsNs1X(>QiAl?v1i57b(P)x>U zdbJ_1t11_S%V6l1EMHslK&iiAo?}j7R+(k_2YIDDLJl|m zWvVvKHuX38NH<}6{ehGsnZ&c=aV!<)c%k%W21WJ4#>V)QC%qZ^BjA;K|9tNILAfxe@=n%f=lf?yU=A4%ek z;RM%LQLHN_=&0Ab*JQY&K19_h23k%W(Z6sv1a{q{q+_8eiX_!|NdmfnUD0Z-dyUrs!;jCCerLU4 z{yv}7UGj>D>Qr~ds}r_=V2Ik3vtCoJExR$>8TGK#UaS-+f@M7g<)HJ7P5q2m{81$7 zS~kQ=UaIwKCZM)xIq0$@YQ2)FD`LYf_^^&8h6aEgi%GfN3#PDdQP~Cqg)Z9*sBE9C zf!1)>Dq*M~$DZ7Ebe}_}hs-h46jK-QnpR5Vq>iFZTrUoVne`=MiI6QQ;Jur{r*V(C zz1;g;6#EOii5Q)o6yN4RDW#4_$7Q~bP}lht>;{K%X;pshFv44_N( z_PFRth~vI{UP)T(M_yNvq0e9q8>nsn&1)ZrZ6giM2HU||3>pr;)yY88`z}04gNX{K z?toCYiI%7ZNB%vp1e!DIFUYwPX$acdzq}T(yS$N00dtm#XXgD+fT#Mk`k@-5+yUpn zNF~r(YhA(2vktWeTW(vvglLx}3o;)ue`d~vc%n=4YI&F(V*16j6P(M*CM+F=z?&?| zL%b}OfpaNT_*K{?d<2$R7Ge|T^4WX~?h0297PxcpigIzVf__DnXcFp<%y1{r3_*|M z9ZNw+B4^e_#cTjg71KGl{&L7Kt-QPAD^c|IT%xZBpWkc9WZ*-ubVHFv4J60YyECr8 zVu-kI3&q++6t7J*ko0chortf1vI@$tHnWBIThI)q&~w|~rg$rXVqzS)nXs=^)a_%^Mm`DJi+= z9*;NP(lNmh$YDS4L=x%YJ&uY6Zr%-~&fhy(6N0?^3Y_&vMkUE^-e9wsYLpOc?eWeO#D;dN+#T%N;r&6-wMn}?F^z?p%H>+`sS25}=xuu~qJbw{^Ri*AEi4fX?T=T7FbvP5|mylf5D z&DK0?tmT1azh#=`MT=rSXI^T4&D>gUl()$Fa-8WOQ;v?=&raj`uI1pz4%uBE|aK2KqQQ$x1G0lsmC?5VI5Q!0({Z>etrm z))g?fcQ5wN_E*{|>y%O4O71N#0?Z5tK=FMEg2Ni{c6GYiLaEht#vs}m$#cAuv_Fcy zH&~48V*4VpqZqm-8_ZdWcY@jWiBkK-q-GGKRH82o0NUJ%;kE*;X%0on@nCtR>F;(0m9w;%u|0 zTnkpiF%aeRujwEt7+DZLcTHOV)N0aNK-;+}6i@PMySWS46Wv0BehcqYAJKU^BowcM z`MVebiu`tx#fLyE+Dc_xp>{?NnF7`kFx}||Ze>zzOrY2hK(YRwfh1fE0i*b4N?t3W z_%l2*985m5Lgpn;N}fMM@!Y30^LreUK(e8PcE}o%1k+0W2#yVbE=|Ai#)lf9Kg8Ef zrnhJ(6mRvV*w{(OuH?)61Cnw59fKrUa!4V~{`rl{JKneqWdnrK8kUz9329 z%%32q%8Kc-X`|^qQySFzE7BLz`{Iw{8gZD%$l(omg5JDuJ!#(O zH)prkYBps@@Sx{HgI@O?$)E5-EOt~xQPawDbr=D$!1K_jba{;ih{B*P}Z z06`LM>Za=7VyqU>Z(jzG2|MJL*Z2QkLB}PAyjZs~*Zli>$0wMk{gF z$JXzxCDvE0Z7dGUZp&l~*h`JSDDdvrtgfl3*$@Bp?;l3HCjH*AZ5F+8dl@NNlbxu& zUY@P!ZX~oyf6P)uXW?bVJ{&1slMUm~9!Fw;LE7!e8laA-y1@eRrC2WXp5 zW!pT0(tM*e*WSLj(GtnG4Ysd0N|3NrUvNV8^9{v~J?SwG5j5xV(;O|JukSU8>ef$q ztcs$2gK=W*c6_j#$Vp}4(LfIZ2GOyAZV3T6J(UwbgA z%JfY@;3uw4i`LUHv{jJmH+;dcv7O|<46HeIvE1Y~Sv_D24EO zzL5g{5Wj$%k+?kH-egvuZ+{kVhE*$1w5Mmok1q^A-x)_^`wjy++cFqC@1E=w`E-c# zZLg{0egCj1vG@x{isy#1BiWnSPArAK*$Uf^U5mdkc|h>#7t%OwT%m6~ILW8^!nEl) znex6bL|7an0n#Y-YM_-$`Zrmxiq6O!NKR3;{5|60z7fOTdA6Fr)_+`$lQS z#lCMK^Mu& zzRy6xwien?b?q8PX&vL;i}bgpeO!HWS;xRo@Cv!w_(UCR<*Ud&|vCKbcm5qo*CXTr2dr zElCxB5SNLsi*1Bk!d79N5YPY1SHnc37w^LT$dz($aDi--9^KWlCG#UVxl1j@(53IN zv@`!|-b33u$z891kRK@8*3pNxeNJWjH&nKNO{x$3#%iVIzGq?01SHAo6TS&X3eQiX zg&gO!;w`=tSdFXkeRR#|2B>yBXx#D;H4s7+DwP7Ir}Yxf0FRkZMXT&kW(RJ8wn7}T z%$?>Iaf7*5Y(2XX)^tVTKS5)df@)ye`U>)6Zjc@Rk`Jle-KxD{+sIF5j_ED?Fs+R` ziyc3a>I2Xo9N<;l-3s)iDk|IeldN5>613Z2wmO8d;`+Q6YjE6P3EMX43p%=xO(%Uj zYU;PGroy7FRw&aGO@#0R9<#UDt?W3MYG30&=ih!BEa%~lSKwf@i*!2SL`&sDUa9Zn(&@47`MYU4uyJL{f=L}L34F>{N{he z+8;1H&a>QWds*;CJ{5%<-rVa9gudpWHpbBeec5UUck3hYIU0qwRkTXP4H@M1$w7Yf zs`hH*s@V1g#eHiGB$+!}!J2{HJ>s=Fl|4Q-X@bAsKYj)1btaaX!>D`J32H~>ma+zv zKo5v@n{Dk5D;f4!##!2%8_Z?qSIq9hJV2C=chrqjl!MdmclB*)@&oYh0SN<@ISZ;Plv#M75xCNhu6u~+>lJ{ zxK7Z>5YUr$8YtMo>-684P>~#)8XraO3=i2!Qip>kP&hJV0VaFKhNP1TL*ZxI82IT3 zDO~plJyk6@BK1>VJ4Es4MFx^)??9nTy$wNY91)TO)?_HB4SFZ!TTGLChdyQT(#Q0u zNAo)h8kgz!P;HKeGYKdZv%OhYd>${yIj|ndMx?^{KmmoK)$B}gPsMk3hY-nyFxmG+ zSD4#O1sZ;4K6I)jpT@V=Kjp{zm!c2F+jc<_QvUfsqjpeIvY%iWX(gkOJyYYm;p(>Z zsrwHL8H%u@o=X2rjbFpD@S;cw8`4pq5IA0&we|>5z8txaN`3?8`6)xLA zvby`lWBX}}HqAiN>m9!dl(`W?vF>l0)8j3$(kt+z6~5(H0@^@^AGC>^n(Nzs1HneE zpKQ-a1GSlHeu*5mpQ2@Kl@xEyFpwNb_k%kAG9_;W8|YE@myD)D)XGVQb;mzL=+0Q& z2_quqrKJgIAv(_9AsKmT!I*(4?;O;FtZLm$AT5ggQt-aLr2MU@Sdx*SmcT*lav@#E zr=?V&kEI>H#$+Y9WhWF zzsPT%IS4*xSB5ZBYw-D(iUVPR$0=d9kS<_$5$nb^#|uy~SkhF|IwdjN7xjlV4`-z^ zX#}hR7MSmuVwf^Pbus=(*55%1cy1?B@?s)bOaCMli~L~4%zY}`zN513D3$HksBFJ# z$Xan);`>%jsPOwo^+v5=uwBVmYQQVmQ~g?Lt}J0Lf$b*N`ZDn<^UL-@A3@x#7u$`s zLUUOSArz750Zg3wsx7SLC8=&t0*2PX+qzKZm(E$;nHD+?i+f@Ic7x=ugKuv;MQXOd z4cg#auJMc2!q@oSlm)eg9I+#bv><7NviToBJ-;JUgYq8)J<;~ zU7s|JxHWzd_6kAIa68!EU?68Z4biDN6Vf^pBYO)3cW;GGUoS&?Z}-ciss81VNxv*ZroAHYPeg|FLX1`w+%RiQ_P?DH9GT;A)xeAzONBgvJ%YCL10kW!M@KXvJ5_qXXAeQ`zd~+SPCx= zfngeg09rzQZ}jw=3~R5q5cJ{@7_6a31|-A%{FYOu?iT~K$32H2=#KS5=Rl*=v1T;I zlWi#;g9ZY;HX>sP;nwH)=Pjx7-~^ksSqo$hm9>7nUE!fsAXQ(zEX1h~o}Z{jeB^6!A^aF^Q! zRj)I+(5gW9OJ_~^1Swe{c#w?T771Ka8`Sl=)7j!(Qwm}yNPY@8!7E%m$s3Je4gAk8JC9|6kc(GW(B;} zA{xF2xOGYJ+buR@dw=63q@V7`+%!t}Z2nG}rrf7Y{T6zmx=$$94ySmn7sa2wDPDo4 zRB%JYbNJIE&woalKDuvHKRY@1b_-C|Af>%Ks>N|`MoQbtly;~l2>SdaW)Vh3s2456615?=xM|FATe%ou{r+N2^IN5Z1#E3R9F{iCh)r zC15|CiGCr0RY4hMl!i=94Xhz}Z4U`3&Z^KOX}3~C&yqB6@SMRs20|&YZ45>G42s8A zQ#?A>Kr*VY_fVlRNV&wc)cs3FG_igHvZS9kteAnw3@z+X&~}+q{b8P@&X+xio-Lec zgy(-AfNUIcqAwhxUzH5+=N;*CtBY*D#H2CbF=~H{S)E7PXZmNihwV0wG2g+OlLNRA zcR~N6z34*}r(9B&Dg(g;e%4xS?Q6AIj$39~j7~9$8gkbU@P`f>2JQj=v06-)|9V+> z=NT4dL-(r6^amH^5pwrpP>QzuQqUrdOPd*`v28m+ZqSw5wo!EaOnLj!%Gx00V#59~ zS%_i&vyvuUKrv{ndKd!#gJM~T`qz8s&uQU$hv{yzTon>c=i!2w4THEXT6vWI1U{$AGX9> zzOcSTl-jgxFLi(=57&Wz{yN^M&Qmri`QUVX42wqRmZUa+9_57V-yn4jY4KW%x-_t` zTfw&a4h(ROjx?xxzDCRwY3!*s7%6;l2F3GT3>41ZH`)ZUN>N(Nn`wR`&2GAFoDZHTk0ZYGzJV= zD_HI#G~VDifw|80VOEeXF2SA1WS8JpWV35 zu>B2+^?N98JZPZyX7k`xULuZz@&6!0I;Z$1NoXkZ7dj!hg0IETOJZFh_9t`P6~G|&JkhtUJP@Z0ZUVS%T|0acboeX2G1lqjlp3^lOUR3 zJ*q;T{Cz(_+>JLam(Q>{1k|!$^^~15F(CDH`pKHN8jTAmNn0DGXTLvA%LZbrGh}kaHEKBr%BDg-bBzL}e}|DR zI4X>0oxiw6lxA{^I%0X+PcEs#5GCjm@~~f`JPIZr*BMyU)&RlBLrwmy8<<=@U|B>U z*MsBPIj}hRQ+!5&_G&G%rdSVI9+?y6zonhhB-jSW+dmYIK-cgfb^v=yYBJWg|HnoI z%jt?OgCUy7Zr){{q(9ZGy8Tcfe_jmgtl4XVdK#@|zMk{h^BnBBW^~@z{v8Q88x%{v znAb5L-)Lh9xb8QKwO1%!J3#U01r)DLqzBt73?wi997Io}O}ZL13u$~_$m6bo>CAeD zw{RD^_1t)vEm&ct&<1t_b5T91mNM&c8T7;_Nb8D_Y%lyadW;r>GvFuTb0HtRfM)&z zzm6ZrcO{D|Lbh}G90Re=u&Dy==|_AO5`k}XWXBD^NPHW@O}`3(X#~W|>$2TWWg7&v zgDlv7fa)?zKYGOTqqT86L+1B3!wQ}xrp}n(8|yklK7)-tnwu|~S2K0yw;(?BzWj|m z4R-x%X1WO5sk~+CDE%RQElrg&NM=>n_8x2)zK6epk+la_n2qQxS^|+l#`?Z2S~X`^ zXT=DP!2j4|psN;0(}Ao=GU9L+EPxjtAfJO-@wg5qmEUD0P~jSE%%qc7exi68 ztmVgPW*$EKn3AV81IgzH;B?1=D0$>hiiaSW6wW{neh-JeqnG%`&tSBFzt7@07#Shp z`^+d5MLs%|H6Gg)BNiGrK`b-GM3cuyvl5Bp5Ui`axrCG-20^EB9Ynt9SnE&mnn3Z& zd5V|!Q@l8j;`wxnXCF~KT}ko87>dW*=?J>@$*9Cimrz)At54L4B`>tSAbZGJ^1q~0 zUT7be`>=}Zirfp;d7m*W)H!S)Ma$j% z-CPcnLZ}GIhAq6|qfoGgPbHBPL-SbmC|FpE!b7Pc$S^vL7#A9&iIYMfq%r z%lP=&ygL}<#=_Eo72F%>BlH{Tn39+jA+3~hP!jW4YJ)0al-R^ZiH+iBSXdA#{4Qw1 z$3n8eKwxqpZiGmq(Kv!^?*bd|tf~sVOiIqXgSGrTJpSG4(5@Wc4}p`eihD$-_N_h% zh2`nfjL=r>fOr5Xi0$thGR$X6PK736+W>m}OP_@9#J2wA@j|~8vVLM{96hShVg$)* ziAO?@eadwQzX~V=v9OdaGt45u>f?*dL(mn&z^&2|Rwq=E$(dns^x|rESRB4>C3jyA z3&*!DBw#=oSQD;N+4eJ)?SD|&{<|S-FJy=9Pljn2UmVs)5PyRZqmM{-aac!fMse6j z0w+a4d>DL#C(FIy5>`^NGAsn&R!CL}oZo)mK+bjzKCm8({On$6!I>MEhG`p@wtuzJ z(gk8{@M~Zt{1KJF3R16D1FZ+G1=d8%pO&5A$_+EGHNT;LZEjRQREiW)c?c8k5PTCh z<|+qMYaeEwIYj>b-Py|TCiO3^3E{lL%yYjwkYNGphEBOPiwv=}PSg>49x(JYK@=e5Ye&y^cmOK| z_2kA29g;D2^fuO@K=rKpsRN9x5In7~Xo@)7p#yFJWBpGZ2I2bol=Qp+WNl9>q=TT{ zywstZtE}ySGIfhxG?&UqePQdnA4DR)BKks9?OIq>AHqBM?Jz#Xa({9Ah*BKZsy8>; z62YaKAAt9Mu6ZE1iEha|AhN%kY%%?4S_OS~g!H#`K>7?~%DQqAdxkA#-(=Q7h4>X- z#7DRbX3=+OF3LtNm@9f<&~}zqJZPkWq&Q7&h8@$5hJI63koM9a@USuRi<=ADM4y8CXyS* zNbP74r|?jy5~hNU(!`(O=R=@ZbLe%k2a1HOR+_*0m& zuEs5-Q1vMIue}z!*OfBYj!^UR_#S@0;GYCT&{p*HdtcCBe+&VOIPn zmBk%VA%`b1FAzSbe_MfV4{oaiBs8agMr%Bm&E;b05`EZz1tOc2PnB%Nn?1@FL%>aI z@RhohgvD#;a{6zAT?M=tn6NzmBRfX)kH$Cp6W4bEZ>tpTuw#-g+iGckLy;kqV{i6< zmokmZ3`Scytbc)o!Ey$j?n9~}Yj(exU zc%lG-qh;W`N+}G_AjO5@132`6nWs0X+2k_p8;KndNde2Eu)Vc`oXv!{k&2@5fm(J^ zxT1PVy&(WORPuzVi3B;=%){c8dikteEmx2`WzCYb%98M*0_nUoJeqVW4R5WDUK$Qt zZ*a@Q1IXag@DEALrQxl~nbPp6nQfPcYi*ZDTybxvFibVRqi*j zR|RpXJ6TIV)YdYOX;Awq?X9=16RjrYXVR=NGF!0jwf3}}C&LP1@w0A*)%FxdHn(uf zJB*2JD~yD3aUxx);rNK$ogN9Q-yTD9a1Z z^084`>oY%cS4079nv%fmWMD<$ec>C}!>5~<>XO?Vp zt>uul&N|leklCs9XB@BuV=+6BZGkV~We`!}jjn)-|GIhP^Oz$TaBT8s*mSA`1b1!+ zKVYPE57hh#QmlBN)@Uo!2^@Vj%OScI0!m1ARcJe7`lBxmz_wS7#tP2~##OmnVb+b$c&^c1@p>-Z{8yRtOu}|ptqbM}{VoVz z?fIy(CK9EX7@`FOr#;6lJkgSSFf>nmfpURn(NEdKg1d zyKzY!wYt2dA5EhCmk|I)-e?w;z}co+Ch83+jMPj_ipL8EklbcbdDy;O$9N)sk~D$b z@$OnfvmFp8F)k@u(gu>ZkH zVX5$@&=Iy8IRu^WE3okE4z~|N60CxN-t4Z@PYz!Pv+EQ1zO2I^Ftx)kCA zJ1T!Ghm`rsD@t4I9qT^p42WQFZTZ#m^(u&3-dzJWks^jMVu#Al6zWn9*`DVY05N(n zg|U4_(SC-MC#S3?uH*co@wtNrYF|zAbBCTBC~aN7UnPrgL6{>PMOsbri-sWGst^34 z@QpBoLv`ftM}AS-koWvf7%O%)y_efbr@fmaQreTZlTuD#M-#n-{VYY>UW&J7Q*0bh zv0(_s8?aAZWXebqo1F44y;H}n%DgBP5sPOr$!H5B?h$8#hpjCrzXyc{LM|9X9>b*j z5<8wh#xFG1(ES3l<3iYHw;6jGLQVX-C~Oj5%YH8Hk!DCfViPH<@qZSkDO_FINS2-_Pe+Rl)yv*1{=)lm61#IowL<1xjRV+_=itNj`g zzEy6NB7q0t%{PoQ1Z}`UzhFcH&iO@ZUKjlS;;qU+#`E;Z z;mm%R*$*~fH*YfMn>)#mld>i&?+iJpwn~BdOCn#A}ZVJA8^b{2R=YbXjQn4fcYeY3yjN zp{X6(z>X*2y#b9I)Kz#>0Y*V=2PdvB+s!j-qAF{mxd@HB6E=Y*cW{i1mz%X|Y|JrK zupD;ZfL(Z>?b^yLH#rlMGa)(?f-}K86Pz=_QljALn8Yl{&P9+j!8j46kDZA}&cs7! z;@|%z3YgM={+D4&A2<_#I}?976Ms4r_nnD9oQdBJqTu<9Fa6EA$USG`t}}7RnfTS2 z_{EvH{XaxO98>D}9|j1!GhuTknw*JS&P1a#(cnzn)QN)s)V$Ig&PD2-iR;cpoikDE zOk8s&uKt%OfPJS+fBr9{6IYyx%gzMdyZS#L@1k>w3(mw(&ct~j3jW*lN`G`N@`E#R z&Y3vtOq_8hYMhDF{~-$Wjkilr{f9A#lg`8mXX1Nj;CVJ7O7wfCf0RyjE>h%7OmQX(or!{(vy~Wawi5dRf(;vB zFJ6ew(YG&rU2O+}!Ml`+N*bt=HCAGM-P#sbBkZ(%Y)OS3El!)4z(S)y*)D%6eE8<-Q#8O(`*FqU^>?v z_A%PY7O`2Zzc(1izQuE4o2oGM03Cyc&u_v1Rf1=J(05KMZwAiRW0}StG+MoHtS{KD z^{vY`t~8KbXcjvZ{~xx#13rr4egAfEZ?E4a5LyV31VRm@_f80*3L%gSO%jR%QWBb= zK*$E{BB(HG5CSNO71X;01*M2eA`(QZAfdx0Xi&e3BJh89Cz84S{o@DUd!L<|-JNYS z^FHtUZbT?p1zfj>mP>9K_a)en!$?#c)UHpY(i=?WwMJEvH-qBfpQ4nw^fE=x_{G8A zFo-HG{h^%0ij7j@ryEqB-bLk!QdP>2wu+k%BdW53*)WvK`UFpKoUp8QD!AmjqsCPT zY2MTHnt2{~kaO`dB(^GUL;$yv3+Dc{?yx>?O$9CHYs>SN;Uq7#O#r#M6$f>sDlSRD z`msvih`i9=NdDOx*N3lvSG6K5EAk)3#q-{mRh9RyCjMJspKjPk^#%mGfi9ezt9MeW z+wHA!k;0i?`X!8d;*!3eQIAy$qZzd}U;MOLl@GoZ2YRV7v>6iW_87k*x7H7up&G{x z7mk@P!j*rgqX$tASjGXv!A1?9t=c&b3n{Vkv+u?&oMP0S1rzgC9kDFy)K%2I$_;`o zB63-$;eNWa{HX`iAN(NxQm6g~?>?ZUd{w8;3_rA?QQe5HhD;fQyY`+%FrrEomFjaMlXGY78;y~j7?sqvFY)=SPW z)nELYD2lwcF+NL;vm_5*?tB%CxZqeI(h~0tRi%9XX=j+xvprtO1ar{P7@sC0HsEXU zIP2RO-w)2m@#6#-0h>(qYR5kGTRiOCPphFz`1;wZ6ugDjGYT0XUB>25UVPMi27HRs zI`d{k8;djRO-I2Cf6SC0~R8ZjG}l9ff#3s$?%Q*@xNRTa9P-dSQ4q=V4l!# z)|cqh5S!{G{}x}0m24|_ihG-z!)0(b=V91er#dT1pWq=~m0oVj^e}ryr*OW$miSM? ztoQmeR9-8k@+xAdDfneX%2wosf2gd3yQHF@Kcq^zaz>{qdivA9xYH)RLd*JE>G%Cj zJjSghoxVAMr8vomaV8y{AEHvr7 zM2h?(!i`NqGl>6oko#4xkFr85crSAERbogpRR1?W%m=FC^+lB7*IekPKW^X){tw>xt{ZkBxMnsi<;M|Fej%TQrKDrvtWH6!@V}G;3nzXYg52Er3*GyT(!B>b z72zAY5`Phfh}GY#k{qwj`-N`^qo&__CzUt;RO4RBi>y71;Be?iiw@>`E~b?)O{2&z zz6l{8=zC_+ZKqe!ZKv%`h`h?t6IKg#uX}=3DYrNDT%-34PvmjYkT_$tPO-;yOPMBw0Nt5eAEND!pN!d-H%G-i=%0W*bdKn8b!TbR7oB#O*E6K3ld}HPv<4#|3Y?z@| z=IddWK_h|}fu>X=si!tkC^GHVBlwU`zfrbDCjF)-YX&AgMRpHLA}}cGCrh`ia%f!A zd5hKj0=G~9CQ6Eta#3cIpzm&NZwee|O}8dk zBdtBGp)f`!1@^P%+dr^xv2U=y2A9a^>=pLK_WAZ%_DAhS_R(;q9c=GsPqW86zcpWj zRrR#_2Xl@2Dr zMG!qJPCo|GqvG^9i0&1q;lwL;t2m8}M`G8C(@_weD^7<(gjAe{U%laa#g9)yT&wtT z2E>($A0LLeT=63!rx`9){Fn)GvEoP6(+5?oXa~`zVg=mY^`yt3q?gMKhbvAjMUn3- zPRxO*tvK-r#L4CXaRA$;sl-p z!-0z9PeA-paeNlUzKUZ-5W6dm!e8F-d&Thy5WiI%9}aP=;<(ag@UIoeV$VqIJco6o{4; zr@|rpD^B6iM8Aqtu$hU@ic>sHEGcf4kitEV*FsfbcM@5b1kdSR+#T+5c|=`OCysKGzP^@}%-;+kd#@$I z)$t0I_5Z3;UUD&M`5w`BSeMRCB*wj?VnlAEBNxAu_l}ATa5(J8l?V^^p0-=gfe1L& zj;kZf_m27*b_bEn9+C_G@yj$GnRk*Jql`b-Pm$Z?{rgAR!E9v%7p>YqDwvCZ zhY6v(;ilnZ!{dfj1Lr7qbXCHlI$JnS3eVc>?c4F_B-`%WzOp?ed}VvqdBCwwcu0Rr z|EADVKamr-4Ba7H3+oB%>(+6wX)}Dqr=-YJxs)M#6_Pna%A02T z)6bdmM`S=czg;Ix6{75M`U4=*jskb!n)0D;YqH(b|HE&$eM_d+weL>456|x6$4`Q< z-%ajg?r|=a<8klc+_v*X7TkG zsPfNma5h{!0=2H$)UszLQ{?@znN788Ur-Tw z4YBw7pL3BPX5T?Iv|1nCU_L6D~9;y^_HIc9jx zFy0UcrQ@#tlw>`f4)2{e zsq`XXBust07rd#L(l=1$#Bo)U4;QCy6e9G+JjrXz_cXOSqI?JO6;*50ucIwoAEiq9 z*7Ed7Gv5G)s$x0}S(%>6d6uQ}CGbc{=XO3I4z{%@v6R4K{N45f-f)3oQBN8(k(;Di zd`TQBwln-}*ld_;=q>yPqt{{tsucA#`c-(60^u|G7T?Esi`%ITdk}*}oj8bhQ{j?i zBAhojiN7fl?rtg`+0#_7hr5rWU*D!m`OQ12lR&LipqY~>o`0GlPSI-RCZ$( zg{&cT>pn2s?XA$<*MN~Gns!wk8tgA^G^hk4V2wCulUpW76 zEe#re`iHbQwE&6!Aq~SIL5BA*Sar(|%iL%7;bo z3fEG1*7%{r&m~LQH z)&>-(U6$u+U#{H=`|HzUf=jYSS1@#Ttxm<5I+f@^d`g-tnF8YyM@p zBKO83*Eig&Trt-N(YmKOL1)7e{+6E4^fa!)Fk`*93gluQ5s!R3dyT}3)L z{OhUoK1Jmoc)}?7=VhvtTa~A0BZUScn8^CabC!v`<6*L_lkJh_ro^k@ym6(<9%eFo zAot30OtQEt{az=oKQU}iM=9Fe zky6GeysQw{m9MAqUFDlkWjxOb2*L6N!D)X+Pz1iT&(cB-&yXF+}ky$k5}JsWAGc}Y1ZasEoxy*E^dtV_?yr7INl-H&4+ z>%PzU#8aCA+EzaVnr!x$9I5q}IM1MpDK(Tn^y@XQ^E<%JsV1upPtMePoSc|!biOf3o6Abof)OIt%gBUF2N3ysRAva=(UG=5sp9>d%InXo zlAKP@1gCTy1urAuwo>TAT~*5F_{@I|p3F?nuQxYB7XvOPf-#RYbr3!T?_nGG)G^|1 z(6S^kSF{<98rB-d8iIu@j4xUHP^7hKBg{1q#NzIL{TxKN@xr@(BHs}KWY(h1V>;Cp z;uU(uEt{ib#rjK_1BS@pe0I<`!+gUXb=1f={6q4#MJMsw5L{M~TkNWo?-yh~i}>5h zu(TeoMvC;-kdOil6BT!Ba_F7tedKx-oJFoJS8I^;%}_3<(`GJ$@2oUe2fni+&!3}( z)@+PUS^I8u3aP~p^4zxQ1#-%}(ep84{mxUA$z>|Di@_k4AbH_@ODd-6)#laaLUSk6 zRnrGxlBJk<%mr2%UB-^KTebr6iuH5LHEW)(5vwmNFc|4<_zmkXs|*vsXS*wWCsYd4 zg(UD{Pl81{U*8Xeri=Uzcr*+{kKN>UbI&8FNDn2Knc^bpv^vgobZoQjwS`&#us&yd z$r`Nt(lOVO<`C^iuo_To@2NaaA@q&*@Ozm(`J1iDzKcl-{LNORd{btG{8v@xONff1 zK;Ek=#Mj@Wdi`BhC(pc>d4g{7UZa)1cs~>IkktC8KFsV{F5bi!<8`c42U}7_=WdJv zUMKy7EwMz}>1a>=q`HMzkWC<5jSbJ&w^rnLBq}YdKe-WXiI=B^TGrV48+SdKSzIJm z1Q1ybsUg*Nu-L|BVj(uQ$X;#aO&nzc*B3{1%5CklzX`{|juQW&sNe#XRk2 zn#>azB`W=Qa6S>~H+qwgar>FXCx3gQ@Dz{k;sKraN68WSz6`4$< z=r^QOS>K&XuaoRpoP~HeN~*8nOS0nBU-H9Cvk;boGer)}H@}bgxPt!SmP2 z6IIEfHvNA6i%%;9GTfaJJDjh7l1lGnHLf`C zxu;5D`$EShg;toL@&Pk`&*~#jdMkMu++Ii;~C5#;^0^`0FoHd2IoeS0ASGas-tZ?#n3G2O$D>(>HQGg4-O)&!Eb+ z`BYvVPvvFA+E-rN7vPJdysVGZ>V7otk4KPX8}HQqRHW*cMZwExa<_)AKz!IC&Ov$znqoxqXn7yCx?Z^u&T{KdY- z9O=0+ppP8-Za^u|UxPL1VqZjw+onn~`@;Y$@m}oPhrd)zw_JdoNGVa*j>_{`|55a_ zwW=hmH^L+8^fOecR+Jx*A{Qm(XEy~DaZIh!5%{OxC)hwPQQ zuk2Io5tv2qvpr|aw*^_RSU-d*G836;ey~&`ENpM&9R1S#45ASRnl72%H_bPtn~cU< zytF17dx3Ac2O%VfiERxR4DTW+YO2A2oP$M<0Rj>FppOs1mNrQr*&0cht9imt#yZhG zj0RqVu_0P_U+L^V>Z{87TJ6V}QS7Pxq87B-pM*AK%?e8kp<#mk1RbmF4Ibi0s>WxB z$ScQZBamHIUUn3}CQX&HEiW6X8@;_r)W~cYKXGR%^4eWhlEQJ>0VFa%yEA{~d%ES) z235+LBeHiJ{nW*f|L^`qZ>00(|JE+*;jfP64&K{`Hd3EUmp^WdMf0^>YG?i`?jEJY zl_PY^_zC}QxllqUA@TN9jDW{`in6(71D&yO_g$}ZSnc0qS$m|tjqQwSGUfw$d>X=g zmOK8^t+#EmO;^%4TfekEh5SwdmY*z}EYmEJ=D)DEu>$XmmZmeNjU>4$zDrjalH2kE zvH=coeBoRvL}M6sR9~f^ja3vovTVG^KjEC>g!gY$0N}r}Ij70)iq1 z^C|G{dR0~VdgLy^LY-KT1Od7+%7rSR=Blr+WW)XZE47q-@7HY64}nq}*BJX{i9(1l z0zU`zi*_&TSE8pMDNM=vW2Ep9Qem38kv#KN2HwPr=pfy@kd%Lwfe+Jas@Jblb@|}F zjP9zd?~Y9c8<~0yN)n3PG_tHLcbWQ&f8jc<*|61HrTQ$rfK}LZ-M9RA{HvgBVC}-c9`Sw;?IssU zP>Rs-`F}{mXDY(izeP*b!>hl-Ar?O5~rTo{Lpg+I2ZNc(|=z%Er|ELsiO?NX{FWg0IerC9nRP9VMK5-1;U&g(si_*#HP_dU`sKH`dBkUKRHXad12(64= zM2xdB9^5SQO4bJyYvg8dz4WuN=AWmm`8zfs<9(Fg75}^T_{9;v8xzoPO|B{^7Z_O2-wvFmk2L{-aL0) zjhrBE#N2>iPN^p6H<95-1G0JjSamI5eC=m3O$V9{7#O^Mk;>W^KmQo34Yx_ilumKt z=33Ofp?YzNbr*pDrMP)1DQ39;sS(q}_h;kxIW>%-xaL#*{z(mGC?3dxNiRd~E^*@u z;78RUrsCVx_+70=C>5_GfepV}^~(@nxCQ)6>OTL@J^cPmjngY`MigIemm2z4Jiiic zj#Qr@am_RMy5CiwBC$3J=A~Y0tWfdLmnc(6Hoh8!WK4H}d(|7p??AY5{D13pD1orA z7{iDMUqUP0)LEwZvhw?ex)NjA4l0oT+Xw5xVlrs{`cQSfSNvv$sRdmU?9B^;I^q$J z9mJlB)Zn*h?F8;UkV&va|re%9>i~9F^L#9Hd0KEL5KXN1|8&2+#&lu%1INqDYn5r zYP?4AY^k$`j&e)L@0)Xm@In?Twe*YQzu836-pWZJBRGWuj)5D4WjcgQJ&$)7Xbx+anXy{EJ?HK)qcvKW%RQkL!VLCmcm#T;?AGt=qsxajx~_CJ%`q8>)%^`^;YV7HlT+ZaBIu&sp0%hBTOl&m>o2dqt5;j zr2Mzk-tueu{@XDB!B4V@>t95F%@CV@@f#Jh4(fPMdHniwB|Z2O1ZX^_;~f9Lm3Jh> z&PPz^bIN)VqLFtGHYv+Q#wVLrj<`c)PEmK2HIqUoaPf2fV~MS}JDjKI_;)5t=KH4* zskr+udP8pnIEf64YAz;6%=LeWH(cFk3dbUWXdA3}mNzAIR+`}(5a15+I{11#l#qhg zXuqivgNVBMt5YKd~>O-4Gt} zY#f=QttBEDY$OlWIbyl(az&lPD`2I+b`yg3{rpycGby?0h?LJ>af~tWwjm@qI0DIt z-|BBvalRp!rWCG^Ql;Q+YmZbty2z}Xj%ayzgJS@KmTvEV*O`X(awWsKxkmq-@T^cE zbk-w$vhxi-(e$j*C59Qk6mN;o8%7(ISKtOtg!A05{Cd-`@IdaOt0LjSL!u*jgQ>vS z1xpeyh+iO8dZBR-mt=g7`++~IJH|f-J_FW(&97M=LlEwJ=F#R*I?il(m>dlq5+&aX z9)h{B@{_C!9WqrggzAFT@A!X%Q@(zHx|#PzsWr*}#%04S6hWKWTSdXLvDt{0KQ4PR zx!Gk%oL~;c5L0QCuOFxuke>@1@|r?!LYg+bKj#n(ez6o*L|fU8d^E`0CCBs}a@2ru zuKn*uw1e+-B|H_aexF z5*6q{avZsbE%!rkO^}9)Z6w|ED%zqgL!F0^TOhY1atq|nGz-pGRSzt6E-q*0Vuh`ktj^BGG8p_Ye?Z=t z`h}`4|1v1o39E+!HLRjVyc1Pxxx8ga?k%3?@KKpQUku8{NPM+tcrIeNI0aoi_ZFgw zm*n;nxwp7W&VBO8l3c_;Qqvi9o0FT!dtr;mNQ4TKJYRQIe zo@KdQWCX5Xvi^w9RDi6HmtlFF18$v8{6<_UjuPgGL4p7#)~ALt!ytHB*9q^Dl1QxE zEGeEI$79Y~4L)s=xtHlrAhGIyc|U3g$63?j#GIaE3xj}_mq#z6u$ldZSjRH zd<*!#W8?U%Lnv|r3Q6cYb_0Lz1w`i_JC!>_>axei@+baP3-HH4iNn$Iwe+C)!(FI; z=qzpE&@i-ia4}VGWsZ%Lt=VHc=skOLgT>V%!a_NC z``BzGQBpXf>PKMv`{ZYPo#zl+A)6#;P&=|tXsIC?lwgOzOdr?Tn>-6ZXgZpjlRZ6sfBr}D-hswCgn zL@%burAH{TYF~63Ikz_&>3C6r99Gmp2cy&Y(=SrQ4n=j|_aFNR*gW#g274_TBKB{CHhaa$>wVDG?tPHqq})DBD%ZAYztJIYCV z?Z6QxTb1=&2MACk>gd1-`Ec#PZDt;8gbU9M91dzXFuuV+xRcmj1Lz9S$)vLb)94m2 zjNr-^?@d}e<=ntlO_gqTpqp+Y4v*4s{m%niM=427a&^D!kUr>`LL+Wl#=UAg%yqH7 zX`6}MJ63!!w^swn|Po8!i}jB^oZ z!yln2J`$ITL&YFNJv#hZL!qIY@FzD(IH>zwcp9#)7FhXTKxAMCe?&K%7eCQJ@VOLm#gT!45Zpu;gr1x>7)im7HG1HQD0#2y1_s+hrIfOcTx_?7~w*KkA>b zWl&(Ge5P;UbWRZ7`OmW2T-lpr=%}q+Ki=@F1~Xg_P-a!PKI6masQN(*e8W67p&ozw z9F-^5PqD&=1{wfhrcc#yzT4jvMyBmfyC1P5kuy>!OUj0=tN;vdNNY=S>QASU{o z#g9Sl9W)|PKZu*hVs+42D8Fa_UVIj1Lt5gRFk^2T= zfUwe2I|#8m`f+2_)>|L5CL#jK56DC|!;%E9 zgk&6sh38MqvoZX+X4;JSNbQL!xMgC1F&*?R5ArGW0c&Fm5;t|^epF84IckQhUge)k z28FbIju+Oh{W~B0ln};n24l5{umGpR;A#Lu??UDf(K3WuPs$u;n!rTQvUu`{}u4`#}|CPKfl|TKfHX|PS1MiN0%GqTy=ka}b%Xt0M zLgWzq8;XAq;omS>h#byuC*t-97wc_Qf zM$5-apO&HO{P`1HO4S{U92eYGls(LUFlVLXnlTW7&l-$Mg>z}zY*jA;XlDkO0_PHyYMa$<) z?g=DmkE_0oPyf0fM*l8ZlAj+#HaC4 z>&}D8-okKmd#0^o$vv*AV6J+#%42dX$ljdR8ER3sH#$h}v9kBV@RmMDm?OEz5Zk5U zpR-38E4fG0n`amd)TLu4ld77|ainCjU}CT3TX}wKmnC53Wq8CqeObZ-H_1w^TX$tw zjT2;Lm=@@w1v+biP%RMR10YI4Wv~wdppzEps0BJ`fgml=UJC?jfp!YOMk%Olt1Z$- z3$)e(t+YUZ7HFvjTCji;rJ&NEMHIkK3pllaLkrkdfVrB~C!Ybn(U$lc0HPEyMW|0c z15lrQ1~{N?WWN^Jrv>(E0qT-Z8&#teR8pUOW;fI)p8=>(J_Asnd&lI6P`3yjP z@)>~ooscjZ(lA*{UtFMGH`$e5MNZ$!CDg+A5p0z{UqaQt)*gQlEUL2=&Ql0Ig4crPe3E zQtOjn`JYeze=hlz4_xy9i&9Xj^~tZ)`s7z?eex@{KKYeepZrR#PkyD=CBL#v>yuxp z^~tZ)`s7zWt@c@SD38jgw1Czpzp}|Ezxi8J(NyI@l!8i0+eo<R4wqR7I;JpOi=+QN>b%yZIMY@V4@Z%)&fOZV1gES*auL? zALD%x0OPd4SS>I{3yjtRg<8O+1qu|vXZ$frTV$jb$kzfRv_PH~7_J3|v4A@Mc!))m z@yAdtFhmOs)&hgHz(6f9Knvso@QIRC*tO4 z^T-$eW8M#7CTQ)dhZ~h?crICXwqP=`ZH)|bFq@}U4hep5FB&4FjQ!b zh{W$8h-osi&V0wOF}{I}DygOgj=81`<52@KnXnpL%xCFtIW9Q!jFTLK{X6?B$gCS; zyKei)wg3)2g0%*$+|lqIyw=Qe=S|LUbUU4GLHNHYZ1xi-3t=F{Y(+BF9=zB2jIt_I zOu4i*nTgSA%maRN0BO|sOyIA)PW89bsD26g04zP@_;;RAb@A%QhFV<*g$=D5Bhf%4 z*(&r*qNTq6o64_{NmHq{zp1so(^Or)V(Ph*Gu3rQ(!me7R}>rHOHJNjN<(}@H(Kq; zE>)7Bauc@-&bC|!rDVIN1V*g~B}U691}4_Q)Pa2nH*VvU)a)7$j)<@KC(Fbh3)lKt z;OJ4Ccc)XD?kX05KhT+mngWbJA*1p%W2E?(_yvgmLqJhJgJ2<34H3ehNTj~pap}Lr zHo6a-&*{9vU=mo8*oSu>ATvr5+mq-bt_!shZ7Fv~i`RbVI>QXUvD}$ps84kU=^w1N z>YGdP4I|VNB)=?ip8AVFy_?2R`D2;$8m|UZkoPT294jjOQA5(GMrmaif)^b|lmPF_ z3Ax1)cVh(`k{cR*&2Too+(hYn>{F06Px}}2P4u&-nHu2ZGQ+d^o%1ak$ zCi%<-BR(>i6w8zHN4GVsr)@p9ctna|yM!KA8WgVefZ=P|GNg)8-a5CNkD71TKzI2< z*@%U~EwbVMug`@+={Rz#P69vr)*4fXbdlSsU&fWK>E9uC&B_k9Xzefb9l#64)c`$; z_ku#M$TxecQvTzWpaR&8-lkfkzJtGQE4O__u+bI+jy#++9eX?U6YX*K8Mbf8h`q@4 zW4i}fr;8`+4|hoB&t$3$By>cFaI0Q9yVa}Ak&5ZI=@Fd1u>)@W<20}AiQ6>yt#^wm z5gBOj{tof-sly$5^Rl6C#Qlt}Il8qXC)!s#^e{gjh8x~Keg+@I(5fX{wH}ycs?T5C|iYw-V7f6C%iuf31<2m|8<)&2*4;MjH@s{pM)3Xnu)@A6%n(vw5Il`H z#N}H!Pi8*&8MZ22I-jo)gcI6|a4Gu} z(VM$BCX8qWaG$+3yd|D94nQ0!oo%raO-i{p6rb(2GzWG6+Xqkr1#h;X1?9U5LlMroB>%3n6K+JCZl=z0z%JQx z46_Bq<+9u}?=selpK|Z&W-7frRFINZ@UrQt{D`JC?Wm~axI`&!1(GF&l$H;atnFi> zh<|+hRFb(Uwt;>L)iQE#DgTh(F2$?>yD+2}MUCyrttVZrh~Hn%5VCejY&9vG85>NO zjnyg@gO-<}?3D6h%X^{OSh70W7)LD0?fuD^fZkQ)w`c_JD&7_Lo|Ar(>9J%jUn=i; zHb0?hX`h1G%a@>~B~1qyOO6^^XOYZ#zDJnzLjIR6@zyg`kCjfnC;wk2E3lm9dq$+8sO6CDO-Zym2HR zkM^W!*FZcT4OBMhRVfqW$eC?T-HiGNj`{G>3Le?ZU`#hIGRpAf{hX7FB1iu&P@dkd zZX?0N^Cx-h;jMAwB9&ga`$3m)b{iRBGIJlmDd3O}NwneAGNgsRtv-+AZSNwnbCOMG z{l>bI_|^4o>qnhd_*jHB*)PNiTM?(AKhm0fS&T>SOz}XID^qS>J;5|ENX>S0P_231dHrcni|2$gat7~-2rKc1Y< zcK@4}r-jUsvXD9IZkZ8=ykuC0iLEiKr-)?AmB`B#-oJgX+@Ka{K(4s04Cr#;_ag_PLTAg6;F?m)l{J!S>RqxunS0^)aT9)Jc+q z)kcyeD+9UwL3NDu9_9(Py zQF*&j=#~qf7LSt>d)HFN7zLxH9H_j+mXwE;JC){TWm9u?H}j| zSeo}o?w7?3<=x%Mpv$grSu>nPa(CMra>d13;iPtwyQ>^|)#da-&5}EeJao-?xxGb9^6RD-I0X+;;LpqNtE0j$Unch z{$=OIOC`5*)0LKz`@gt4l4UbF7s(tS{sdF8yijttC&@n)e9wSNR|U##jukjLwgVPP zZsoqhBN>(-L!t(bEG1Q62?I!EpV%&I?}V9|8SmPvh_k#qkv#vS>vy){Zjw7ee(|`g z1A7f{vEA`x*9lh*ySFitI}UrpZ4wp-zQOszVz`92d4%b{a+l5c;) zA(;vKacmz-2FV>m9y;y1!yJdQ$7oV`#%GU`Lvlx9k9eivYLHtqN6sS~g4#XJ9@K2f z9Z8G8o~{%LBFpyhE)tnwp33gEe3;~pAW7$32bsM}ddb~~*nf8Y&7Q<#lDjvWoHos+ zycx*q-GSc|zt????BywvJDj{(=i0%vrVtf+k?|K?#~4t#qk7627hNWfUE{!E_drK2 zSu%fg3@LqlgL}vw8OblKGYEAEx1QSoCwrz=N)HY`Zx2 z{y6s0qstM0Au2JikQ8kTx01c#;S1PaL){$pVaD#GNXS*wF`vE~C%NIl^sVa$wp(yt zW~0ucMfvKrt=g2hfc!g13?+@FCj0D~seuljntdjd7oCw~sN>`Fwt2+MavwR04$WZlGd+brNAuewr`My`;WcFb2 z#vMWy@AY}(Dmcm_!`@}qb2u1 z6e%uNKPDSTj%-VcR)l@XK8SvagKX}{abTsTE|q?h_eba~vVFhbLq4aBqhCP&dCPT) zJz_lY^GU>#o^0Yj?t9nFD4T*v`t_0%Hz5(!pk;QDtX!wi{!e- zg5xAl+qD(09~eyPDtX$-7b;vqT#M#Qs>E{JtzWJqMMvAllHkU{%gNqIZeQXG1i+bs z_2nFWoIXt73Qi*b zI!ZQSfaTgM3faVm=(diMO_(~AY{CG`w2e@*2~&cSO&EZZO&EZZO(>umn%7aX2~&iU zO&EZZO&EZZO&EZZO&EZZO&EZZO&9=V6JMg+y1ClZqhu4N4keo~K#8^rC7UoMDA|Mo zDA_~>sG)fsC7UosDA|MoDA|MoDA|MoDA|MoDA|MoDA|Morf7+7>n3Z@hLTN~I+Sd} z0F-RP07cq1DA|N5LCGcx&_r}wN698k5lS{;07^Dt07^Dt07^Dt07^Dt07^DtfP(*X z`%A%3Cs0dN698k5lS{;fPUI4lx)J3pkxyUpkxyU$ZTrk0nu$;hPG8o zHeu>evIzrFvIzrFvIzrFvIzrFvPm;gwhn9)-wvQ;ljb7pDA|MoDA|MoDA|MoVziA? zvI$dyl1&ntiEis6wRI@jgsDTxCJaEyCJaEyCJaEyCJaEyCJ%rz-MSvy{Zg{YgCe?h z-LxgTYJo5<&_xS$)&ij_z>GiEg=mWeYk^K$praP(pap`oKzknmMpH+9JFb;Ix2F161DE0{67QT?U{;w|^N#CA!_w0{>`%zqP<^E%28X z_)`n~Pdomo{6kyhcP;Rn7PzGa8nwW$THu#vpo!>qvl*d8w+1axuLZnX;D#2st_7}X zfvej4x$=r9GcAMTJ*|mth0iP&CMHiS`Ii;0Rz5bNMUp2#UA!naD~6EbPpoJE&!WY2 z$#llCR^o?NuCzu{xhx%EVxAS_>*6s^<7HGJl{{g^_*s&pC@^oB*cnUsqhzE zpE8X|PRZkte|^DaU=I}5Esq@s+GO35OLtpabIMviQ?oor^4LhqORmpZFkkXmk+#Zp zhy^D|9t#=0+I5@-r$`<%NqgC6o-YMU9+TYd6<2GHS@L={W+(5NOYXODeZwq;Vg=tb zht6cF8`_Tg_~|5atgymo9qb9oQ%cSUxY%V#iI+SjWN$PUZqEz~yUZ>xiIQhF8pWa@ z?2DvmVZkD;X+-KHd1lFtZCqX0c{SF8J<7V&{P{`cLzN}2TVET0@i~wgl4k}94|H8* zJE6bid5m;u@7m3RgC);&a=pE49}6y(Jk!X|AlDx(h{^m^@_7fJMIk9m@;oYk+0m82 zu0FuX;CW2UspT4zqF)#C;eq`4q-vzy%k|$gKV6rRN=NLH2)0do`;`)oZ zeai<(o-C}=O_?$l<`Km6ZvDR2zJcXhPbT^3QP%;sZ?Vdip{#QGc*hQrJn3ZCG}nHn zq;emnk&NlSH&RQ?s$uGvW63g^B+c|W!*blmNu>8IpZgfL zG7oYX&i1vH#Y&z8xh~Vy(`Qd}Bu_kP*VlEO-4iZt9P!I`U1e|XlE^n~o{?%7H7(%& z9^tdZjkULYvLV*hz&15g@{AyF#Q81{<5ibOUWj)cVqQw+lO)e@TyFUOmiJOux>JKE zDi04lTL1NVeM(W1XP7)B(bbK8jbQcDqZlQy%u3yixs2fHWYtUIk1&TLnIz9pG9%e_ zj{(b*CC?DDD8;p%0i_VhGgwYcb+z&7u91>w5P3Mw$8e#%P6v{NbYFvLhU6JQvNBw5 zwo!aca^;mpu3Yw$pbmx0gOw8xeyPo)Wc*9L29v55kv=bs#gZq7?40QO&HP~9 zklg<@#%1+Pe!yTL8-ofLle!gcUm$+#VtuOP`#6cr>f-Z#gq_DW;zC@)$2C^mK*z7|BOVo^jYC)~d(i!OVJ>Z%-O03h(-O9*>bc zW6=ZUh!l|o>s@eG!82t0#SrTJ>r=8w@{CdYx>Q|^&-y2ZT~vmHwr4ak_i_Ej99KDB zM1{l|;k&SlyWK_3M7kQ-<>yBvPXRqvd;*Je3R;rJTM_%1wv_MZDCPTko4tfz#(;CG zmxFk`0og~QP?8nTd8{~_RRdAhEBy|zmW64Othlg`A3vcy1bq{gqyN$8!wE_XqUac0 zmzYYDQSy}03!xhC{B!lcv6YkywN$<@`Y!yJ^nlhu;@wI zZo#mKEs8t;31t}d4GTUlc@~kstgf5PjZ%)gRIyFqr0pOLT=;f$TjEy(M!@iq;Zxc3 zz?<`NTyxMFa>@;A!6K5`FKh#|1LfvkpnOaNh{Ngoio;zspZNRx+FGoVXP*44zl-Di zA3R_Q3Uka)Hx5Z0B1So-){@*(q3v+^FD0M&wJBUihF6#@r14tVLUz}jB)1?de=GQl zy`YOE*&pYoyyH~A_l?=@ej<+NBhAc-m5-5RKiov6rSo%@M=YwQU=P`AXyNlHKPt&i zQu@CF9}_Qb2L~DdXTf>)IBb$^mk0kxH;k{$GxrL23sGeq`A?>k#61u~!@Ba1 zz{hGcMUstZ0j7C-KJkAlKbe$}f>oNp2rxg&h(~#^z{mUu5Ej3C1tDbFtNKHl8(9`) z3x^w-{fre2D|EQq;>J-}HwxhkOn~s{*(77G-yl+389R_YS{TpTkl`l^ZZM#vliaPz zfRhD(GGIBVJgvykQw8j}OA3~~M?Q2@A zPD<)W^QhTTOnyEW(~YHTb*gEldLj*`H&x^D_&%hhWnnk=mEFE3pt%)%&WcI!`Iv64 znh&(RS4~Tlm_L7T3|ZD@>|AmL+u0Vn)wIC&(o*#}insgCBps&q@5cH|CDr(o^{om& zVzC}Ieq?Ox!VN4IUE?ICHihr7SXhmNe9@+`l0Bl38aws~_o=4GZSQA%=O(hY8px$Q zy2eIq?F!#vxgP#CR#X~49xoN;OEHF2IofscsWrOBLR^7`N7$2#uQ98;Qk}VO&FsIK zJ+>Y-Ch|*r-*bqnF_Oxl!mVt%(KR9&-l6aai#cixq<6={?HD&6c_U~vN-nYPII+7ELiFI#x>z%3)VCi_J3toRTq+Z0qNkJ zjm|n}t#hw)yR*u<%DK#0;w)OzICkEH<=(Kjoca)+8XtAK=HJTd3mEnfwyFBN8hx8a ze@CNl)#zIu=+xX2_Vxon4J*JNjJm_kn(do3`bLdjrO`KN^z{$)M7qN_Rp5b(de}OR zUa8UFAdP%sT=Qaa*trKS(gx3J^q(~P8I68gqo2~~CpG#BMQ0Xk!;WhT{HW27Y4jg7 z`caKutI>~W^zWgUHQg}kH9D**@SR4l(dgA0{g6igR-=ET(Z6=WSjry2K}~_LH2MLJ zzF(v7)98CO`j;C03ul?{W3fk5V7Es9T%&)c(RXR|of=)%=$bDws^0uqEzs!mHTpb_K3Aj9@zu-1N_~N_5{*7vqtDXlGd21Q zjsBQMpYEfV#fMGv0pi1^YV=1n`Xd^BibkKT(I;v2iL4G|NmwxpKrhnh6Eyn68hyM* zAE(jBYVOjN>We3@8Bq1%8hw~Xe@LSb z)#yXyMpNNO`m)zG_-h)SX!NxjeT_zcRinS6(O>q}%feRs0%5B(`b!#prAB{Iqrafh zpV#Qm`RKU0pY;La!=BOTPiyq2H2MmSzFebMXmp9ym7BYq1)wj}==25H{C-)g*}g=h zFV^T!YV;=}=_H*#3d-UwCSLXyq&;&3ny3rpJt58d~M*Yag$*O$LPHKl5!hNO=-K!^(*W>cmvm^Jg>SEL^ zEyXMajP2RPZ*w??_wjil>=Cu9E+W6g=RMC>46dG_R>Y7WJnq?~_U*`EwvMU#VbUux zZz(&R52+rHI?9wtS(W@d1xvrlL%XoGdRLDl(&*xmfu!W3 zxWcDW@9I(HW}5F2_pTmE)~EYoQPufmSVo@Dlxte`2qi?v`OM~tEWJ;+>YRCwE%smS zLu?<|c5YROJvt4u zrQ-K{6g1Dn*g0lMO*l%aSAi7!1wN);1hznMO)nHEEmfM?5!)6)d2wyo0)90;QJ}b3 z-OsZ@KG(QIO%H5S9|DBMd&$j4|SeSf}gMmWa+)g5OzLcUhRwkWrWzTGDFV7t`>sRygBljQAaF_T0_ zuwACCYo}J;OeiooWI}@qO1Cw#k^9cBrsq30(p*v|dCqumr@`>Q>X1 znEeaiWS`bvHEmE!UC=37F?z0Ahgl-@L>3B}vamOHo}QFk8Eus-<`yp9-3jS2!ntua z%W=zEXQtyzN4m3&Z)e|w46ft2O8Zh0zc(rqJ}ps!DM}x|HeH7>62D^~+IeHcF!ocZ_mz9*hQb^_2C- z0F?E{0F?E{0F?B`0F?B`04nK?jR#gwS#L}c%6ek}%6ek}%6ek}N_t}eN_t}eN_uMo z6q-~$WxX*)DC>;@DC>;@DC>;@DC>;@DCvy>DCvy>6w=!yeH4RG)*Ay*)*Ay*)*Ay* z)*Ay*(i;P`BR|!TewXC@`v2H_^YEyOtPi;NCF#yWc0%?9NC=@jNoQe8*f)Vh0@*|o z*^CfOgdkz-bS~}-i^x$_2)OUy7I#5WN7OMPNoUkigSe423T~q_>U^i}O}gt=x_ZL< zeE)pU^Ub{PM1Q~Ab?er0>eQ)oPLWDd6EJo=Sd2XyzMAJ^0vz!;m(uTC%M~EkAy+>xZ=Apq9M@UH&1jkqR6={Foy+Z zv%oACklbRaLM69Y3Xt4lv)v{>Mc*{G5Xmo=`pp#f3CS;(dO~uGr2xq-mI5TVST~TO z=#%_n-H4)3@{6T_@zBs$-eB*OoDwNQbV{Vhn&gv6J;4r+KFNoRdP4F^qyWh$kpd*2 zM7pUYr$p)z$tRHlB%eeIkbDv;K=MhX06p8fl24-h3A>_Ca!Pa~iaz3$D4T4OPa^e% z4iQ=+T^dL^Gk>Iumwkpd*2L<*365-C9PNu&VLClRjxy1Wr@-?61hL{F2hgM>+uSuVs+ub`Zw^` zz4cU_{X}d+2@oAj&wffFxUW^5{U-~2&H|sYz-bofVSz7L;49X_^z0e-kuO<*_1Zl9 zE&IebEbz4mczV}GO(My`)FsU#s*P9&)3dCD=~>pn^sMAy>Um8cJ5>9A)_sc^aAf}> znbk7Ib-^`gu5V%L-M5(q8d+cy3tYRF(U1+Hd+bt2$y za=YT}$8tn*_8%L%kunx2Wq~Cuu$To(SYQziTuxNjOKF4v z#Vk<70t;DS0SnB>2`61NBI#f3y41(6OMUFR)F)q;uwLx4)F%lYYVxx}rw@f4S-7Zq zCJ8(0HR(V<1BED1>RtR*F}Jy zXRoo3yvhQvu)xbK@DdBW$O5f0fXuTOWC#M!v%qsK@GJ{F!vaTH;0OymO#s7I>cpPO`vHEbt=>T<`#r!qa!&gOF1)Mc)rB@I4EhV*yD=q8eV(ktjgY zktpD-6wyl|l0HWPl0N4FTq@?hE+q>n7@+UJEbx~b=#S{_`_qkx9vX_iKUm;DEO0*y z9A<$-EYQLN2kk6!fCcVjfqPlt9v0Zo0(Y~(T?4>CL~kDqAd=6EqHiAy+{praS>O&9 z*uw(5S-`%FMRu~l?JRH`3*5>Ax3Iv?EO1jl;Ew3+yODk51{S!U1+HU(9V{TQKr;i_ z`y33?*Te#Y#>jT|iES*fRXQfwqzGIXus^_HE;T)D()m~WedL#DoMULwf3J_#U8ntr zc1!@ClRSyH@-0)ou8dJ^BIhJiHI9Unc6cM93}{cZn3LPl-i+rKkTA700xS<}7K`S0 z`7_%*&s+&rbG6B`jOi&Sw<3?0eo{s#97`&5&}Qz0lUrb6av8|6%>)aBr&G#w5SUW7 zm1Y;8Y=pa0%HEfqNSxdx8pg?qMA6OaeX`T2)RWgj+$g!TU)afw@WH6ETj(#jXZ+quf&5&TO@sunR0Ty^r_^DgqixQVOd&P zlDzKMz^=5i_h~HkWIfc3DLY7iPr%7KEUBg@6~6}q@m9gHqGC+<>b~#}d+Ta=BwgNY zF(=o-{IO+s(9flxtcCB#mK~+#J`X>DWyPVJ|kvB_dKRKy)9r5mXzgzVA1510!+A#IcNH> zMpSx8)fbn%5R_$B!_M`Urq-zYD~k2|?yrbyow2?06(#v6IJBia1S}iM^{w?!RP0)$ z_q$5bq)b=9>rJ*ulm9yZ694i3@%}nL7qlGnPllo+e%A+?CYzE?e*WkEo&K--ANIcy z)-{ceGzETxmqWhjcffClA0FFP1t;30)LCk>ERVJ1jkRXSXJ(GKSn&T8eM&B);4!`b zZ2jn^%f3|Kc*gik+|a?pVz4Z z(($UyluEi0b-bVVX}OGs%c#ZCP`b5pkKNr*y}$4t@BNv}{KRE`zxL({$OjdrF@Pa#Z`~R{p)%`KO1i#IHd#KWx53IH-RJk ze)W3=kNB_A=4$@X9LH;k=V^k}UF!YnW$I+rS=Cdj^{PCyi@vG60q-veRyYIq1e^|d zC}2&1#r%u;WwYHp-E1`d-E@bk%oOAQC0>ZU*58V$>G!Hr$B;bxV@TR%QtkN6R##Bm z4F*kMsWOGMMS@bIA|vDQ0)T%)aAaJ*s_$)3hQ#H!hFli^j9P>=y-x;y2+F;2li~HE z_-xH_%$_b@qbgl5QB?V#IK6qaqDi$vfp0NI4%Y|ie%1X`_a)Hq4sV3WKI8wAE5sG^Jv+ry&#ZT1tpf}DF`aR|V)6b?)Ovg+QnQq0T*=455c+2kP z`sw;K^JC^+=8d`yx(f4U=Beh<0qFt5%)gmGH=htCOlx|zF;DDP&k*g)=8ccOEH0DD zWvpDrLNmDgrKH(7nvyXYTxKknN#`m1>{gIi=n7E8The7L?ABR%(8M%ys z%fxe;IB$$yT*YI(Q6dw=WkztBXf88+5JTQCzUaC^)Bv-C%S3URNG=m0XYADM50_IE z6UJpixl9w6p~swEKKr+Gf3%IutmiVp&3N~6?vj$XTj`=iBNri9BZ;xhJ2xl}QiDdI8KmuCNLFO1DDxgK0!8ivJSn0`}Fl(M&L5dT*iU6ie|}p0l8JmO=QDFY1T8iKb+2G zrg536TxJT*$TjOinv!YO1zct_mzl(6Cc2qHn)L)X#cI~$xlBHn$>TD)5|iO&VawtE z$o4{0C0@RzY*0*8JgT@eeP*Oi*`fVdyZ+AUGcPqO@6{gGPSkxMb}=}9oEUNEy)!@7 zsMjd)`oSt?l5{nbSvS+7>iv}PV^zJMz-j$VTWhXrX0}qT!^`Iqb`>8XH+9AK-(M&` ze$G#N{G3sIT-EoZ^zVHa#DCX)_jur8#ckvzH^KbJW)!sg>t}+2To80Q0W0<$8KsR= zmnZ`MF#l-|@$d3`*Z85~9sPT{>$OdqE7T>b<;qJ%%NKd`FDh-bQD~n8ZC_VJ!&Cn* ze+zbHiohQNKMOn=*c$ji;O@Zffn|X+06%I|pHU#qDm)-v3&aPH~$X9Kf`STL)np#Vvy{wc_SMm`ZUITV<8v zMz+c-yW$4yk`PG*xc?U)Gt!NsS6s(JTEz|)(kKKLQY)HSNTqNDWAXiMIY(@CMN<$$ z2BoJP?0)}$ul|3xS0j17`cO}0kcnhkPthNxuVXtuDl9_p!b{qY`WcN~#unomV~$a2 z_`m=LIP_7-GBR0}cv}))DhA&;gYbT~OgQZf!YjMf(%-%j5Pa**EGTOaS}XojYv!pS zqf#W{ySAW7;y)?Bh8}0o_|^wIf|37qhX*XzAsmuJ{Ha>|*Oiu>< zs>;Ow{-O*dwzB|LRC9vRQ8i2ot%zAPE-S*26%v+}f7%Y_4iE(3&S4I_TIzs-rLZzR%oA$b*iq0qhI13ITm?{6YqHA?@ATml5g>wh&^ z^{@Ed;8*FF;ioiq8Xq=pHqJ+z`cH;;40jvW607{mekt(l>d`0#Fz9OaXp{m5>2F^G z6fYk=ruCjHMlV)Dd)4Sj=vg&72}Z6P_@`Ob1OIe>MgKpwu39;Iv?iV$JDU`n6#<`{ zYfK4#e;W4~7V6`5shWG#Q6sobZX#uhW& zdod+K*Cr_3voI7AZZ-S_ML+xLV=)?Sye(&ZR@RqId*ws?e^g2^3SHM?rNr#1bfxf) zz%V>Qcr2BzRl3(&Ij#}9Hj0xle^yLPh$!dqP{)0hpcQ^-E;GbfQO@O$w^{hr^j>^( zn$XpN)f6uu^pp?P-G=v}=#>$g1l)sXVk2Z_;T+(XKE`Y1nOQO4UZ1jQK5|^eBp`VK_f{Q?wb-Xo?{|ZjaOcT25@fG47i|*k8*838} z`>uUyH4n;3z3#q%;+X7^^c*CxaJt4>uMZv4Gqgm>5o6UHD^7!v2h-> zU5M2r*v4CONCTtr&*y)Aw%2hXN(g1HBH%7Fm}dKrFzzy}(qE#B)V!;HQnf+}?T0h7 z$xYo~g*A^Bx@U@&DQ$39ravFyZo`YDA2O}uEo7QnEqKu|zb2eEn=W+Ezf$3A>%sDNbORL5USfi66_qAN7;!o_Oz562&ewX+h_^!99fnpoHa<_2kUu`#g0;=e z!~eVJ+Fl}bPs7@h^l}aE{v`wRHvMoYI%|9#j{G`$h@)kk&^;B)!sK!0Mee4>vaG$v zkDzc`jy^UUrHaKi-kQU&XPVdkZN^5JBKF!MPtmY@FGi?Zq9W!%)g?vTyGGsuZTA{BLenP+nuNUZwoL3r8*1AZ zz4Z76%*4ZWi$b+RH5X%}zEORkdPa2@Sa!_}5AYm?lSA@5i-PAtO^|76yhfw$Rkx{M z2>c-MnZUaOx2v|Ro7GpU7pSwhx+{Sl1P04J0w{e+Uxy%I~<5IgB*2d>jxhclR=i)jlxfHzL^!=C1d?7L3PS1bm z{^&O@^Di#*E0-Z2J)F~Xu1infPu${u$ap`{xXJB7?ET;^pi^AeYN(F@amdfw`Vl1|TG;4;s1ndi97vn=B|J%5I!+^6S9xy%tR z^E8)vO2)_zTc4Cs^y&E%T;_2u^B9+T)Wfi+=Z|TBUL2f54vE_0t(CXTq^-|Lkk%sp^Ey5KYWfbrInr*Ii$57UgE%jjebHL+Y89@F=y zhhZnlA0CRCB>&+u|K>91{S03#UQ?SD19>J${tC5O6QePy|ARZKkJWHXd!!KpEXv`n ziEWv4l~8nH-ssjB9P>-r8-36IEJiW8QvfK=N|B5dAVo4#fE39{0a73%1xSI66d(mM z4geVcdsd1D96%6|f&nQ&3I?PADHxCfq$qL4 zNUuK&__2VI1q>{prvVcG+pD7y0%%zP9qch$-BYt(H48{#nbZ?fSSAHXVVNG_ED6h$ z-Kf_)f-Nu78uC_DJ+o80!ahF<^AEly@>0jC=Z{6-HV$@vBr z*uVnoS>PHLsAqvX7PxwloL|R2Qp*BsSzrwdT*U%4EO4b87?AU;-AKQjU&R6|SzrYV zEN6i$SfH8(ss@dTW$YuDv%pdoxQqoVS)hUi$_IdfJP*BP1IU1!FJ*xxEU=gbN?2eK z3tY+q_F@((Vu6J$uz&^Tv%n=RFpmZ1_5+J71K&G`ePlKZ%wmC=#MNe(vg-f+6Qxj9 zxbhF>m&(5>pHl8sZdO*2P)_0`lMTnxjb_>AQQY&>jwqsU9NAXNxBT?BQoiM<8Tpo< zW~ePc**wzJUfD*FX5?Fbnvrk$X~x7Y&A*@Nx3JJZ^5gzUejGyoCW!l+KrRzN7>|X8 zF3!yT(KTFV2bU4JOtaj=LRZ1T{ZSK_sp2x5d5qoF$fI1FxXiU&W+Rtr@XFu`qr$bp zD@B;~T&9-GtmQIm1~Yci%TwXHYB1H$)Nq+Axy))Vvx;F*Ym^*K6|R*GAXd#zbz-8uhnM?R0bsqOebGghME;F0M*cGli z4yABi&1KecnOR(BrZ)ypcyZj%@J5NubS^WE%S`1mQwA~ke&S}Xa1{=s2ABdaGnvaw z;xZHEjGdbO6XX=djOQ}>Tqcjp6P%n&Kn*AYOD2W-yWrDd(B9}>E8M|Bkh-WD` z6USv@xl9a~86jh2`ycrUAw3*-$$msLJGsp5T;?_p!|s1>^-zrZaSNBZnakY7Wp3Y~wOp2~*^CySat?qk1X+<2^;d-^|-gnSNgzmlz6l|JEMSG^s}_ z|EYKnth>fO77-wHXP^&jb#*?8v+@KsRDZDW8FVhU<)d?m6I$%~tZz5R2_2gt4eMB2yUbyByD-Df$jVxH z`n8BvaA4WUWN4ck#QL;z{Eepw-Kk=6OpCTZpRi_M#iY#`B$8>xuSS1gU;V}T>R_RJ zG=6nW4Ha(y$A?$e>^qsf0M1vH>qZb~`Tv6xu;3?jkHU(RAe_aXb~qYcc_qX@w6KTq zz@*T7}9g-zqd0v<3I_zF~sN@RnjcE(lEG9i6Cv{WwCB%u3u9+^`3IX>LLIkR<=(48Q*mLrOCm50;6 z$9_&CymWC^;)b6^1~9*!IMX6t=#Iy-@#gImo4Y5_jeW|b^kWbdt0%!zHY*mNZSxt2 zz!@zIh3+`9c@{awdIs;Il(JA*_DAe{5Ffp0h~l6?=#Isbs;eC-lwiSak8!Gub-9evoSeeiyburOUBR^N@_z@*peEs zJUU@xiYQUXTXXRR*(BojqI;`Q=#Iu0ka!naq|b#3rXF}@UHJ%RU~@V>Q|KO!#bBq8 zp$1eoI5HznLC?G>U7RJCbh&kW*2UMcR_Knx!mF#BQ|ZE6k3T-ajG0Ic^Tqff5|5Xe z?K2*aQ~4^PJ5p+c8J?+)k&;K(g#{@uLXedT-4R$0#^25G)Dzx_Ua}SHzR+&FBj|tz z3QNi{$Ol^jCxMS3kr`g)2QjwC>zfsUuLf=k%nmrOf5+gbZw`1e;HrR;<}b|$%oXNv z(<#&Krn#8X_)Y)qa7lbfB<#_d=8^#S^E&8xGE)a9cLh&Wxt2rQ-r%XK-fZb_-zCuI z3Ypqkl@f9o^GPOzOb1mu{?D87A=4AjJSa)7dlUhk=BrF2{d$Z~8?M$Hwe6Y}>aohx zihGD*eoI6%E{T5F0w}wv;cA7TV#Ak`jzL1>GA(2rWM8~wnI&`^v2-%X=zbzuhC3kM zHYj$K)7>~$4N`X-ZEgud;%s75zh_2!hSzRrzEbGcW1uA}I}>DTTC4T2S+9f^*J3dc z2<4}*5FJkSaGlX%^-wHDQGr$%+xl(b<#6P)BtM7`n^L-OY&I*LI3lE4=+;Vo(nyWU z_8H}C;f?P?_iJs~9XW9H&$*$@$8&_;1fg4l<>4qd(wbiNv;Lp$J5?b@m|<9K=EX0t z=|Z;}7ufpx29JUYRi`TIp`}(A4m~4dgW-I(E*Fmc8L3IYT9ZgT3}wys@hVQ6;5b#` zH<0*7Pa8wsn6hi3tvzfa)SWbp@Y2GZ5NTXUlyH1ORw$gFQ!c=x!DMynTB{=P(LS1ilkAIBr zE`0%7b`H{|eZ;CNK34?1V}8c8(?84jreTvlLFcborY={R6?Y~Cy%CO4)ZODn6}En% z`#n~ea=;J;P4^iNdV3pZ%;C(>m&Pz9S;eBhgZL8>WHd-?An`8euYsJ>l|pwO*0Z{r zipxZKkZ@bXQfT^9I|GWoP0}QwqGiPt7bNu4M|XOEg@AF7x!9c~deWhFHwHNJE7wB& zsw9`URmOW!&A2hh5nFZ5JX*bq-x;wQmhK$0ki^pF6U`9Y#K&ffGczs~x^39fsBqA+ z(#NkT?eP{X%_#&oTG>)}kxU)~NemJa@RH~w&n8WU^BJ*;-iqT$#I-_q772}1;(r%X zvY~KCxdZ+?JpC4^dNrV5!ysXi7ys3`a%YP2Am2gB1H3$T1K#^Px*m3IpEN{i8!L2M zv3FLktfZDGQ2rABgtrxn)5U>8w*?EStu6IPY#flvtCL=c%M^n@(G=ig{p8Gho6tQD zi`}-Zeql^jzik1ELn^mI&(~SS&{95q6te{QnEcAIihz^mI=l=$#n@{Q^wV@PTD2xv zB`D!&^SCrIP(8A_Sa5C^8&+l(z{M;e{u(&)xY-Vte^j_dS@y+FUCbxp+$IiJR1MuD z&Dx5W0fgf^JX8hVQ7bsNioLd|6jdxkmO_z5(n;*TH05Xk8qIJ_VX_TWTn431lWp%X zz}vY6i>a?K^{k9gy}L9Lj{VE}017EIu!!;9nLeVMIh~gvI5&%($CP~n3O^i81Yj1~ zWst~!AH$zB!6pb!k}3qpn?vboMI3MoCcO*C)(=vwIK%b|!MRE7j8cc)Bbr*RAqKS) z;#10vBXBiD3F^am#Sthuf|H~T!PSt?nBhn%uiN*hwFcJB9ifXA*V-(f*$y~O8zVSL zGLf2^MV>JP!Cx$14NXzH8Qz8wM-fOCoDFdL`9&cx?2E;raK2*Ua|x&li8~)$nS2ay z&UY^soFr2S>RPlQRYK_*Z?)p|u2pcZmm1IP8J0wxv=8e{%*K38BxA+@2q*U?2;vk0 z-D@)r^LnELQHYlS(p%mUpi-8Ckf6v ztb0w3ENKDM{h4q=+<=qRS47dnEuD|5b9yIBa9)jt6IE0u>@OU}O-)ckLjT@Y)O0g_ z?43Dvj}n~gaBSn$_AH#(_ZMExT?0E&XeJPWL9_)-hH&vhDOzyWV(E>Ii##I>j=!|{ zTG$n9m<3fY6Ezj>$!K`<(d?X-SuHr%V#^SdvS(gm-48vN{*yQDfztwUf^!Ylv7uqP zr;b=k@u$PyNr-CRpuM)=vvs1&1VOGJh`MPQ^TL{iXw+YS~EQ$y? z&nSTUP_%)@f4{&8ZO4KxheEAh7ndmxH5B(3-=O0J=au46lZ|6+isL~a?Q`a#)z50p zA|t_<9f{M*A%b%?wlXe#p8Atk{yA|S9C+Pe=*q>qSh8-AWGy~sJ;y+SB7Y@rHyoHr zZqY1{xW!y3{`tFM=bf4Y2zZw0T`0A1==*qq_iQCXy5L;VuaYy#BC6<&>I zoPpynIG5wV!K_l1bVYY3Ej&W@_~NRL$3_<~n^Fbm6Ub1EGuIIFSjnwo7g6L)ZzeifY0&zuic6QVS+R%~7z$-eA-obn3=XBC#; z*jU;?>tpR{mJhw@W8T#fAw_U5gZ2rTp{;K|UTRT#kw)HM?`RO5B*kHEZ34AcPL7G#b}%p^(GNYnYY zn0v2#bEe?D4Bx<5q9Oi(rUU9!<+x2GXQ(85-Ng&gd4iK9Ma0c9V}QIZSrqtwP&aQe zFncJQS79a@R53sC5%UQDTm4oU^Ym})YP7NH-&F4?Z&Z|sp3b72APGH*dEO&eq3vjC zJ2d@{5*t?oT)WYneDOMUzThmE8j7;j!UbLVN+?(#)R;)NJmg#f*8^XRH>VrM3eGZd z_R$HyQ2uDcHxmPyAMAQ1dR{VWPMGb9%#;7|4V)DiH$|n=H<%aF(xdqI;N@^^aqN63 zS~VdQ0{1S~#-ObX)9Phi{O)L`;9P<)K|Mr%wozPY+X%Zp_cKDzo`qM!0Y53d7TWv% z52t3Of^#vx1c#Pf*-=gyamJgn<9&WLuM?alQe7K8Q@8a{YbhRNh>6xPL0uj(oBBSJ z=9G>ib&=HEw3gBWi&x-+Wmqlh5V#E$RbgMd9e!_KDits9&7-3yioJ>E@4td|Bt@8* zyzAmml8E>z#y6lDE?=e&D-TAlfwB3XT099MxTVoIuEH2sOpu|h(42~jBLh^pNcOeVo0t|Sp3#OV2l>?F`g#* zHnG)wTVOdm@X>;kWX8iA5Q_0$Oav*TR84 zvG$9wLWc`Zk_Hd;{c?9pqrQKz|x7O(p^m} z6!#cDz(a#MLo~(K3(oPVc~#*QV`LDkd|_j>CE@6v|M%Umo8R}Hyo$J(uX4+>|d zY0%{*n;eXj#m|QdUFt(eNmB$|Zcg<7+V6SeU50VG&$Ul$!c{LQuTZ3+skHw;&3I@T zjuok`&3EL|JXGiof#Q11pM3aq zg+SEYdmuh9FA6sxXp#~qh_76ioFRlqYs2~{h^M)sJs|c2Xu3O@PC z3dek^!P5Ao+*u$f7>{q7=BL}CYRY2r2F#$2DkAp4#dqoop*yI*Q#}G7J5~8Y*7=JN z_mM((Ahtq7L#apE!Ly8tNN5X7eKgL7Ihsj|W?$JjIdT$x4g;i;sRb5G#`B;Tvo6Nx zFi+?<aV~R4Y8LjdQ#34?d%EF;rDz-q&+e!1{ z@F)daiP-Nh+MbJ&d_J*`;Jl}uD^K6I_&R8NCusrfJV5pfqPvnW2M+Huz8THMmq2y# z{16OPb!5##hkzO3(==#j$cICBX0cutoSs}JIOkzoqgkZclPDKX-(Im|-?G^IQZTJE z@pbC=*Y%aomovc8ayu7Wx3+c>b>Qi!t5^e7Z;ZN{*}{4+!?96t&cQM;<*;Y-1&;cP z70~j2QeGlj2(m;)8u#%&6lqQapb2EQ*Z{Oo9bgT^q3DC8j}mPt^@%eldPDyo96%Ne z&RJNx=&0mb%*E2{zM5YO$4t@ty{*HX%A!ZbOsO~}HOs}~3az$MXxf^78T713!3}%= z382M?X2^NTM8P=&U$S&*zN5x1^x^m)WtTx=#r$U2`DcI1d)&DAu;_W8+zvdTAsMhNQs}NXp3(lzFbZ?mXdltsq`p*@EIOh@qFb6X2WwDXj?28ew}}ps zY5CBLRgBaAsBGE97HXhWUP$=H@IDmo4I-*m7O~BXYtBW>Bs5!Pi}SI*l&<0N-zQuJ zL3ai%hL*rQR`2!R-p#`WXBHNPhcZ-iXG6>3yp8b3Tpiw*9G*Hd5#2*XFI-~J^PwVm zpZd+y1!t!Cj%}Xm!tt7lP0*BMsl15FULrWH*fqGbq;w4{INFoWiDLuxS0dz4_wpq} zI4!YRa9Xf%;&nqcMeBvLhG-mlQdf~naWLe;*D}Hx4`T%9II-%uA#jgt=viA)2j@?u zR>96%Tx_xKCy@cQ4KXT*H_s59u>+d~`6r&9l-5j6=T;=*thbVk^0*fHIsxX4QZ$tk?{RXx9OfQJ zR@nXO@V`r!jo?<=N|YU(20q%*`%HCg6PzPNsYTiH;mF_04#JUw1TF4DFpK~B82M;m{Hj`LBymrBHhg6=#FP?D1~|ED1eHhNs2gv=6y0SsV{I&(ys;A1>+&Z^NclB$1K0c)$`k|Dh%Dl**If%neU z8pj*;hEDyz4P*818ulBi$emNQdMNxcNDCu7;?h;;@IuUvIIF7nl=xTIcQ#+$pn~c} znr!&_^SBnc;qy3~NWtDO2L9>uwz$8GSnErt;{F`CZ0Zzo&s}XAg?C@>H7wM9ukFx$ zpkAg*Qk()z>i0}|Gmfia*bMT;7nJjH1?y>#HbGOGemz>m(Q5`3G;DKRgng(AoWYB2 zO{@t-)t0Rg${OVw=t;`H8iF1w)8VB@L?iKGqvA-^wX@@!?SaDJ@*0k~{hiD=nI6Pd5WBTn_uPAVA^9H>-64QjJ_!3(R zf92&=`7*&tEDQMI)U7wrKE0$7cFi{3>21nIc{TGj<5mmmV~RrH)btW`&8}6JLd(BK zqkrbil0f)Jb_E*`$@$a_!Re1}SX)aQZSlx2ExV7AN}Q2`Qps;%sio|3V9lx60d;W( z6U0B0g=6MQbpIzYuw?oZ=c0>lI&|D2fh0KYsUZp0IVBisa6&U1EE(CO$oADndWW0` z`FI7#0|V7JgXF)Tf*0sfk-Ue}Gp{BGtaG95p6o&h>X=J*4z`^Bs2Cp_7-t;V1gBp5 z7%g=wabZ_kJsgNs){6HP{SRxBFAd305%7xnplO?bm0zggbA6ZYMy*Btl4_;W4^Ttu z4R2l|I7z$;P94X5_XLFE?j>JA%Nxb)o`v%f3k4?$QXz_}$Fv01d&@##S5m@BZzo!u zQFx8u)L_G-y%-zbQy(m<0k)4&-i&96gJK*wm0Bb?)mSNXC1~_mhatG2A_{u)HL-AH z$%x~LHgdp0bOv0Ad7I=vTUSxq*7I*;xc|P0c*+-sZfoECZ85TIo0!LWj zX$p|?**--fV%qK}S>Ooki?%-#tBXxV>ta*Uy4X~-E_W*0fqb?uHW#gn%|+{C zbJ4okRJ1NO6|IX+MeAZy(YowxE?O6xi`K>FqIC`Aq8&&@>ta*Uy4X~-E;bddYi57v zVP?esVKjs7&gm>LjRmH%z!VlJWPt((u)8KRh-(rHOk{xxEHIu0@>w8{1#%_8p5bnQ z9QF|#3uLoE77Jvu0GbZu63!wAFf-veIihf7u)tUrNN0gDEReHU-iK4F26S>PWm(9Hr)7U*L2kKRu9kq#C(#RBat(8dBEvB2Nmz(79z z58a5g9QA&{0`IfHNfvmI1^&hY@3O!RHYNK1;{Uh64*z=s3;k>SbNw}b z?S2RR*7)W5X^p3h2aRiu`9__g!_Z==HHAX$r^w4KhzJZuL*1ktPM1)C#(HbU8)w<8dZ);sr*0*O4Stu5#UwI z=6x!EEgT$S%xL{&s$rN4if0>b`-!nzx-`1iThElz%_ZMi-GK^7u z`=s>un|JC+L_SBl1yFk4JkgfS~5EGxwdM;b#Lq2({L zMrlLO7IOqdza4KfkkqT>m;@3svqI7bvMOd-$v{W<`rNU2WYw$^I;4oWs8XuHLz_eI z5+k;iSy27K#3-0toZ+vh%ZuieXB0YC!SNnzh%y&;Ka}`bBHBLjCTO%BqN7qio{wc^ zxdYVc^2Tz?(+C|aMQcXbYES)gi8bRsINdTL5?U%U%(9wszSMD*(6K_aTSTGV!rgRD zXQOU~x&upevE+yy4==HKNEEBrN@j2>nJRQF7Y!Csj^J@w5eBJSa=~#+&U4r~$(gbW z@;+53bdXT6h6cJTa^XU~^&SnW6I8z(7Xii>3Ic{y+)SaPy1!COJxvEiM~8i?A^idM zrtsmzES`J;baxE*{-p|q&Q8vPw&?cNCF)|IQUv@vAi(7Gf7P$ic$J|*pQRh2`Ca{m z>P6*I#Zx3W=;m;A+36s0>-F_fn9sr8?#J?@Kxu{cq>2c*ae3j;J_!eFgV;i0i#$c; z!l5N(0(Q?0?ZGO9nzR+P82#eV1bQ|Q5eBC&~Fg@b4V0s)W{qO8SUAJ zht$|%LWdN+jw65?U0BkKiNw7%4?7SKsIt)66P-A`4ld5PZH^T>uE9Fi)<&V1Io2^| z3Yqdp506;`$JQyAgbYk`w9ZTC!cfZ# z_;5?1yw!LERWEeZi9I?>)(!1Xj6JBbWE6FNv3ISw_MusHTwamyV~-=KvROUlEbWlezw0#A?ilXWa_ zYEKqAYWru=d{6aqLAj^wl$OlOOt7YoiGZ}Qi9@R+j+C`n$8Fn4u7rUhhdr@nL5HkD zyvENGI@Vx?8XB;@JOx1e$jCRKX2BJ6$6dYUI#9(rb?ls247NJ8-s@io-YlyFL*=trQ^NJ8WMXLpbS+6 zTrl^TKJ`E6_nPsr;aYvMu0ngMW`?>*HC`Diu8I-O2|_1H_El4Zk;I-?!SSnxeJ)=G zc~uP-I!RJ5bSso~N_9%qeXz?vV`z<9Cv>W?km_nYGjI=NTm)dkbr3WqKttx+(X^;x z+1}?ruWkmRlbn~JjFJ^woV?%=gg>Q^gxeo0kPkhcBvA{U3M>hS9v(Oh^g7h#$3C2x zEuK`OZx9}sz=Pns zGA>3j4-h(sVVUA&bxRXcX6U}s>3dPpCnFZNmt_o1bwVi;ELL&UN?JG_nuhtR$DBhQo@->jtxR5 z@oZVSk{(mI%GR51Caq@y>mhvv^c`jvS>A}!2%VBY3tb#otB36$*d|$5+M*!l?RY== z=;9RUSTA&%#qX8vyH1yd-KxsYg58fqM!;n=)8r$IS602yX~MFqt4HAh_`vLj>YGMf z4ozQ_O&8lwwm@)*Mg};NliyKg z=0p9JBO~D8>x@3lsgdJKq0@*pDlezo9cyH`WII?^g=!&7eQ6lH^3)KwFgl^rAhx=E zPFsto+yln~#_C{dSaLWt{mSeBd9}M%=+ukF;ZB5-itQ890CpM6NwgTc>xcpPIBZca zW_n$*c*QpgojNR@tpA>V#jWbZv{qSn@|LA=p;HUN*NDp!dhI@m>Ths(Py{?OpBeQz ze34~R#wY@u=37lk{(9pphWqqa>MqfIuHLU&ri>xV(w~vd=&nri%cCze9lDDNi>5_< z21m40h}ROD2hdT(x4${ZJs?MLrizVB`By>Qr|2Hgl&Qw_eS>p%a-2>I1m|e1C;GKe zA(e2t1Os<4zw9>X$qv=Qu6mPpI9bPhcvf@#xM~FFC~@G|J7hUs4%yPKfrFS`o6J#=;Qm2Q>OBo@J$f>kED zz)^I#Ggf|4Rt;@WU>eY4UoT)inK-^Dm~bl@YgkhwPke!*V-*zUs!R55OBHj864z22 znGt;NHBR%+5}Zk5^DaZVLV1==c{RE>k>m>^d4tfm(3hhzrx~z$6R~Ivl%ZVRq56~3 zmqh7~p#oxh2MiVPl{1J__zJ;ElFpMz-o>6azEE4T0pi|>KJ3k5ky8@-J8*eJKvVSLxUYBVK4_4u5~ zP@JLOZ3?`a`3apQR021{bPp6COT8AVhNE+O+uMQ3(DGCPv4kHl85RLM=P{dO-Uv|% zog_4(zFw|tD1&F;1?S%yqlGKhD8u3LzZgx3Q&CJ#)fpi+VFJ375A49e5i)%3zAuY2 zh^9fpLbFf=k{uYjwhm-%?N^*dqdl*#s40*<2KDtgnrWTwMC#QT)HSw1WfiS(QLyFo z5PKYV8(~;oX18%PJ90F%y%)F@ik1gzz_EL3gk(M|+{l>EIL$yNaH!a-vI&gk?i;M3 z`rL_?DFSuo?@aIepYc0m{K(LzKdyU5+klDvt1$!2LPZ(biDibPA)-@CY%DW2S04$# z9roT684d*o#>UC3c#P0Vk{hF$Z6!4YF7%*2mh`D6?2hMW41-U?wPw%1jejA*1XX*6 zEtShh&I=rCgwApJf|VoIz4_9ynGhX}9u?Lub; z7Eh)yHA0G?3EvC50+KWk9JM489(i(TTb5>%&^cBtjov+?-96<&`0<;ls8DjY{e9r-UU(U?sCJ&h~_B8o7IkNLbGhMT^!U{RcxEf?I^nQDU#6@yasMZp&f*RM)6a-|X76K|Q!tsllf|Z|S6#SpcjN<* zup0ZY?ZVih^&x)yBrJ%vg;eiIlOHzlDuNPjqF51JuRT&EI2#pW zlFs7&Y?w2+F#SX)NfIT>d3V8hDsX+;?a;CglfaPksNh_$;)zpP2TojaWd-RD&wz#Y zoK(zd(VmwKfuCEG2WyU;((tN=&U`El&uOUPzwcn=NAkhX6XmFkaM(&e%mauvkZ2c@L{R3p~6u>4iKgk%@?5XlG&-S`<}?CeRnYGCa=}v zgwE{#>@H!nl3lzq<*~s^Hm{USp)(7Y(UmL19L1hti#Dg*(@w(ChiBuVZoN%z(dD!` zMqqc6TnV+cVbp0Iv>Q{_?pt0^Br{<7RMd(U0sk`pX!^NDOD(jG2>w_;M>qqJ2!TuRYq6gy z>25kYI`>vJnz!N5e=>%{pP55c)={aRCk8RS z9ANlRCrP=3bA^%yXv9qZRQ{=)-{a9!=N$Y!3S!zrX?IYF0Eh1Oi|}z0$SJWoLg<{0 zCF1hj;AuR#Fgf|jeOZ+{cr|8NIP~mh_GP@j#G~R_WONuE#sqqxU-)d~jj&5UAHyo~ z_(C?^dG%5Voinjsn17g(DtIHq$o(jw67EfjfFTPgG?vT|8=c-Q;(7dT=u?Wh6#HfOcqGBBIAm!;)%h=(T#^ZOOZ!=;It6Ttl?E zLAGknjA+Kjn~H_W4-xUC;4<6Y=oUmK=pa$b>jymSA!#{Ru9T^RyYIu*Fn(o1zRH>} zoxL6ToZ0P^dY2%Ql?-!k{AoUG`qKYnyhpCX@Q(g9-E-PQnyYZbS)rV)NJUdxbCA$^ zdH>Q!uPmVaD831*w%JlNxc2oMB5u8%c{{IVaJVnU&)K$(mNmIhd^oLZNW&5h8J9^d zOYetvZA`cq731AcrbWP{jm%{6(Xy3dQL+(!dJCEvuHKMigrJqVNs@6yJct=&9O12> zxNKEGwK_S3Y^Lz)i)LIvGiE3)pzN6MWmn3Z%V@M9xuJ>M2W1Aq?b$mO4`E@!H>5?u ze;;5JNFU!?hTn=On)GZw-ITf>)8GcCscd3_5wPs4A@(B<%Tg>57gU*R0N!bFuPVzz z_K;D-I2!@p&_KOt2^NWxkm}l8*rm=tgy-8Aj$qcYoY6T$lU`WXVzDgQ_gbvU2gJK7 z;j8Rq^t{?QMt-KrsTn!HEy2=IHprG-yki0nNHalc8xu7w)B8HkDY6;cZxI%W2a~do z4PG0Fxku-X)xgRXW5NateqR6Lw73+DliT+o;nc``q4;-HlteZBqE!24+<=oj&E)75 zQ!BZReASV`Peb9f9L)QZLpFW5Gs?%@P1cLr8KK)`XOUQ6nPhQ{4!x7;Fqz_R1pbrB z91X^L7ir{5&J_I_1q7X6oCt+M<4hwA!~OoDKZBmuEBsQ8-y5GbZZb|XstoTMZZ#AW z7th^#=s6Irg{sLJ7M1II*lo+Osd`Hx;pd$E)?2I@f=bo5PC~yMDgFI6Q2YyxlQM9( zM4mr=m-x3eAUoqxb+YKurT9}3@Q3*q+{oVQca3p{VY$9cSE|j^B&j3Nd+JYeNcnL@ zGfwS(FF9G~U-&NYAt;(@)WD8=r-n*>=6xs28&xI4_d7_eM3kL^sKt{p22DbPpaXfQq#XPkQt1ys7K(?2u$QV zNY3(#8ZLB_t5k52#oLCc&FAT{`A3QUWz7O&g3tDDjo^H!W2?|fJjU@u@m|IOYY8YP zhrKLU5P3a-2Lr35-^;od?^?ST#=TmAJ_4hbgv0s6%o@chj#P&@h}YE6YZUIUODFDz zHd`!OIvcUchU__lx;60w>Io*zgt#=`rXzl!Wqmh^`OK^My|03{I9xyJsZ8q4(p@z^)G>qG6D7#A$B~ z80f4L8;I5d(QsRbcTopL<)=wIEal7St}}Q;&1o@*UFcjUb|P)cBx+PB{CB*n1lpd< z^`e;j+=p&a1pFt!&vXVIN6#A17~a;O(w)-2uQ`EPL7q|WS8O5s(8bzjoFZFbN?Sn) z9EljVgX{os?A1j?;^deR0SBHK;$9#`=#=iglUZbr2B&U;svqXg89e(rEkT;MaZvL@ z`|8j-C|sR88tT?m=-|n?pztB9$2i}JcU?mR-C>}N9<~AFQs#~(2^)0KTyGAC3R~2W zqeeQ3vmLH{GG!?ol_h&+c?xliM&BXPdu5RCkdH4XaRN-47p@A$Y{oy&O@X={b9In( zGwQEHJ;x+=G7>L9+)}B@j75e((7ys7lFKVjy&UU=P7))qbg8WUq(XiRoC9^2mydzM zXYw?#J}Nd03idG;AWpf>XmwtXWE+xzeM6 z;J&fgum%dxF2;3j#4IgQLqsR;ObgLPE=mg{GqWDwK(>#bCd7T(L+MvQ(WeEKL#d_% zJ>H~%0oo>jGCFAL^<+Tn;f_<`VS@%VPb?-1og`ua&w%Bt8eVI?5vp8^r=SQzcf;M= z3nF0iYu2GDuxo`*5;{;_EmvUiI_n+8_FH`m20^WR)iyL^G;VImg>!Pt0Qx@Y;_p(~ za1-{|geWN3$ZSk}y1_>gtJ)`~_fTotY7`7N>ML~f(fU6@9iKEV{l z8>X0&h9rL?3)xuDIE92g5h3uyn*1Yp+XWe+c#4pZ-lCYX-P>6mr=Dm@=+cQjC-WZ0 zvTwTx?_q<`rNu%}NXnEF<+}V9*fj>NBxAm2rZw+du_O(aR8vD+BhVtdHRBLki-TXW zMnO?-ntUAbMtZ8yMN&Hv{Y@5Ec42}EvvXDE&KPoV8CoE_NN@sc2HJhyj9aB0{F&8i zFYXPTuf+Xc7YRzJu9jy5so)b zM5k%+D$DF8LygAz3_PuON$8muH0$#=iZ^+}; zm_!{gwhAmR23mG#mq61G1*1t1ph1P`VS|+4%Q(A4WwIhiaZaf#)(zL5(LSMV&`#Fs zHEn_K1?~x~3``C<7hu=itr??!O}$Y0k@7Cp%B z(&~~@+U42-XCmOyQWtT>K^22ujG+EV!eKc7X0jHpdM6^3Y_cxSv=Ah85q}(vij%qP zx4#nikhHpq0(j8s#v3qLOX7wjUmBo&eE24?e_=~SfgXpatyoP?_zSOGoUtVEf#_`1 zJUjGk!UhOjF~f_>&TB@@g+_9Gk`=;C?N2UTKJrPtB||T12)HRHdC`XM1%orUi$pz$ z`jKbmwmqM)5iFBp$B}47;(IaVXhn?l=pu0sxQ~*p6Nes|xSLp4$-@3v`w)+Dv_cn& zc_2Gis_}|dqppO{6Y~ua1K6L-8`z$J%7JK&PT6ch$Qe~&mSAIQ8yj6xTQa3BVG zsy4^Rro1cvJ+B9+3SHz<52BULpn9+^Bjr=HP1Zk|6ds1xL0G-J3GoWWCL$3I=*2`; z3Z>47S-LwN>E_}Kfe2g)8?E;9CR?vI>Yri;sT za+wbAj2-K6$~%QjJC|wWG9Pi7zk6ZqcsPbH`p^p{F&}W5_qohTF7qDCc>4cuEamR? zce%_vT;^>qb3(@0JuAAR_bnMkGjDR4<6Pz#mwCg(*z?AcICS^7zV4wI<~1(!DwlbM z%e>sr*mDM=4c%3EiTk4$xlAjUd4bD3PZ%+s%%Cbf$NkZ>T;>@rbCk;*iL!eYI5;Do z=Kkm@F7qUpd4k8-dmrafy^nF3N4d-+T;^e~jGfH>hrCjRd63IIz-8{|GKU8StQG%t0=5fXm#+Fqlq@gz1wi+{;iN<{mDypUd3MW$vOGd_Oh&fu>~4J}z@7 zm)Xl@?r<}hPERg?_P8mQ+0A8kahaW5=5~p}bYg>M|83kK-O6Qd;W9UKnVXQYdr>!V zEZoTb(G6VYdM-kUXEklJ9)?u~&Uz?j zj(*2wzU4CCaG9_B88529SKJ?+;WA%xnbTaRhcMn%fiJi}`kc#r#%2D=Wj>8c;Hd(i z_^IDhe5`z14Y57tE0w>&j-K*q>Ti`!IN4J^B2rtSon!dH@VVhb!>fkJ4f_Ls2>d+o zL*?7>{rRHE(VFtWR|6jp+#h&T;HJRUn%SCM%}7m{Mx*{o{iV9yw86C8bZOv{z!~sO zPkDu1WmH9}QdRk?IjR~}x$05X-Kv{ZjjFS%k5%ufUQ*9d=c`lIQEH>=clAr^N7Z+$ zZ&Ej^Yt-cm%oCTJAr5OMW1QzizFI{ZbOBb8< z(k0J&Nxr{}tvs9c(#2-Ibg@}4U2N7%7n}9c#b&*9iCHgY31``?mo7HzrHjpa>0+~9 zy4b9jE;j3>OP2LgOgQ@v+W>E~zzG(3iv`|ff#WRjI}7}VfIQ*szt~59Wr1H<;Aa+i zg9Tn^f!8QNOgQU$l|sas_X-QV%mOd5z>6%<$^tL2!1HXvS=V#yBhRwHGc0hF1&*-5 z(=70m2apoZx}NkP#E_?OJ;4Hxv%q64@F)vB!U7Moz(Z`pS=WQ?BM-2^{VZ^p1rD)5 z3kw`{0|N=~54e&3MfyG#xR(X)VS)WDa5oFw#R6bwk$o(1CkyOlfjd}W4-4#Ofn5W@ z{DFM$I|q;f;C2Q$7tI30Ss<$QI#>DIDoUoGi(#J`!2&@n5Xb@nEMR5qKDRN{`>uIH{NL2t*_D5YS(J&)st11Doda)cyv@m^Dv=n zWdFes<>S4tApQ|@z=@%=SB$|UmmwxLa5M^CByvcJB#XOxu_Ta!X|3s`2hT>3}SNFXib}RH;6QaRnsk;v>7Efg|FY0g-T`s#w zY7cTMC^c-Cz)dvP_(oN?0E(I@mdM>U)eqP59 zlk1A^k6nq81t{#E8Sp$g%QRz>l|hjrytbb$bdeiUabh_r_vhSJ$3JXZ8D3i*Tf^C|0Ld%>7=6=nn!bJ`7dlI68Ao{?wuhpy2R8jfB_xdDQnojV5hTxkl& zLvl_!Oilw42PzWziD%lfanOD;<1Q3AQ?4`ppPap z0!sEwVkE~KwsSFP^5U|-5eE{IXaoD1icUmf_;mT>7|7-@+aj??Y~Ee$IaNm2&@OTz zHknOyHfcu_`pbP}G(QHlGgbQG7|V|yoz{!=iEyCnRytM~gU6})09f|T()0>m86WAz zb(>e$1fh%EevJ~1_E96{MZ>W<3r-AW@{=J*@@>eY0pa-g`2BDpCpHpZeTs3e;;bg< zKHVi{pQ9%`B>X?Ey$L{7)%HJr=ZSkSAd@I62&f3kEFcaz&k7D8;s7cdj-ZlZA>4DC zUiC`K*j7}~JQ_CLOT9{a4X@}G)-%8+Y|gB-`u2K_zt1}70_R-r@$bF;-_WJI_FZS6 zz1LoQ?X}nXtY71Iv2|-pMvKX|$VEpUzmJ$Zcux_IF4B=G*%LY@MYm}(0B#9zQN+(E zU}Pu_9)d1J1P*Bk2!v<&thRyMfyA*(hDk4kzeSUwnx;ByQpc+sG&AfE+tRInT3@%` zY%R4W2K*FoEa19;qJWW>Z!9hgZ&_@KG=FM-%)HM$%N%5S&vc)u$}|Z@72Y-iDiHAP zuc`lK_(Q!~{k!cQ+vdQ<_Nn%dRcCEYc9VUNA>mrXLW52Jrv3(fzCKv@w(cg~a$T7A zU2TJQq1K=|s@W%cdIF!JK(4hyQ3boIb+TSB+0+uQ|r&=mH~Dfo$@J zqw_RhzsA%TCCqeQ$Blo`BDBz)`ce4QoqimlV)PqL&j;c+U%%e_&F9O+Z_b>;H!*LI zMmSlI_{|qv19{eQS6XJ@^9`TIp<}hwsTzW<4?hRkdfPecsen_KkIY}2zA-*+*s70b zQF(b?At$B_x5*Z$yz>oMf?}KUguco{#HN|D?n2sW#sqPDsr+xQJOEm0I7s&Uz{jUPgGhNv-uN~!46v6A&InNN1jgH z)>u)pn;m&{K61%@#vMxIPfP;a+FBXfJgkA3pjV+BQdmqF(mn>D>M9HxohRJj9@0TO z?i}fq8(|7>>DbAiARrGRrMmAtg7~JcNeAdaf~}lT!l39g2_-DuJIu8b^IHA{;d+pw zmzu-JV&^D#PsHegdZ9he5LRr$OJt> z@EyXbtuKlXXQFgu(xw4)_!{K-9)0HmqwZjVXM$NxjWpGs?6RROp#;S*FLVfUEQR* zk5m#Dj`WBVl)T1`l)r zB|~ore_|0ker9|K@#5L_SC%BPyxJ%|TNY&t6Hp_33;hh_PY~n=ghm1|#*RH4^)Ori zz}Ue`hw<}_B4KXG4O*m{e?8$K!N*Tq73u}3S7}i|*Am7C4sg=Eq~r5w!e=MSQ-72; zm2v!udC+8njVN8-!LR0?mf2iN494&$=7IqQYzFr$Y$%^6a$cY;m9huM#>p5d&D)5A6Y|h4?AOH>6rX~G7V-3HJ*~wa? zHLEYEcf^FSGcJtDdGi{tRt1{vDwuc{3)g&Dx^6dOM~aEdEo`3?1A4}ul#Oh9ghP~uwlpe!Hwk?m~4e@ zlOQfUk+RQa47y_AuAt<=NaRnfb~`Yn7>z(=vJS0dm6sywctAZAg#}63F$8o0;gLeJ zH>?bNQa-&3+JQVWt02B>)`U4?`VW~*{Z-;>7X5S70>b411OO88AclbE5kT?1@8$|! zq(p;abP<4*GSHvXiF?@DT6K0DHYpS$7c%`2W-T0KeR=?rB~K6l2J*cm`vg068dB2p zRCEpNn73TdX8afxJ`i<4X;>gM1cBkXaXX$^zKd1<8Z|~tpZV!YYZzOX>VXNbFb)n_ zHH3j7w`y32Gx}NiR4J*wDE`C>OckcuL+y23OBjZWKasAu6ziamaY-hfzh?H7tI8t#>h)J3<|5rm>=;eCk7G!3#D%$X#$r>Gx?efZp5$B;yOkV&U@m6I&qRbP|)A7X>TRHZ(3@e4A`fSLhHqO)JnL z>{pWQBa5ZB7w%wr7EPQcEkih8Qf8BH+bJaTB5;N~R`RMW_}j4C*oa?33_>dKK;SH; zDTL#Qz$j2lLvqGpt!1OUC4VY0fzyZ(De$tAqh@1w#35F1MOt^kY_od}4}Uq@ET7Pn z{;*f6Plm%evpfVxen7@~=$YUq%d6OZ8?u(-{0gDIH++Ldy_F1;MlTD=wD$e#A|J-YQ$U%tyTi^wAUR!Er#juRATz%g0Pf=F0gK zW#YTZijt}Smvi+<;Miqk(pe1U^Qb^$if@Mj^sQ82EnA<*8b@6}(B265KBbM(`75l> zbTf^a!&aRR1oPI6(}UfnDbDDQm7FupL<3N@1XL90`v*I_Fv%R%--L_goT&j23qAfj zYfd`QuTX)HW0fOJRu=tu`ko>EZ5=h7Gevw_{4%lSxSg!|B^|Qr#%IJe{u*zVwB`nL z&SZX7vYSO+$Mva(Zp_GYPF~Yf+7XS{>Q4@;%SqMRCqV^bVyE#NMG= z-5$0-N_`CrYRZs3GnjLZ1vgavs1e4eza(92Vbt^NsD6;NxnmdSOhEJP+Z~BQ^Rjo+ zx3jY=ml#>fkEV-;Nc%aSbB@9El$X!R6uSAN=@uD%C+8e3ysg7lgaJeeRDh}961z1@ z!i3Xv&QUahSl5n7ye=#KBC?L%u+cV%<%OoKjg-t^A?J)2#GxGQFwsvIwy~otlg!dK zC7d%(XtPe33RE@46<(7mgmt_ZmM6@IrJrMC{@mNF&KYQ5T^+N6c%nYt{nDvj!#Stp z6;K~UoQf7*$P0}($|rRY=bVN%H8rSjK|HEvr(VpCE}eUoRFp9md3a83ZFMo;vFF2t zcK`~OF6;VI&N&rL%F3#R*E^Pz`jqf`lKyZO&N&6oBGJHt!Rc_l`ck%Tin(T(bWE`* zCigCicp3-iG%j7EmNGJP&PgJpNMWFa`f9_k$k^p6vyL*(nTwXFZev?OkBk`?`G$m( zqndM06gU+*)I=}1BE3g4Bh=*_(6Pp8c&Mt`PS$gLD3IL?lP;AsBu3e22+5=6N!7}> zv7XqBZJwf!@yR3zbisEn0cIY0tU0HDhH|qzL{~xyIJK%ok=F3%Qi6JbDDJhpuWZlFkrDxOtl*rB zgy&Xgq9KZQ?P5ne66Vm#kT7(t;+zY?P^hkhc0xg}ys0bKv9pIgW}=95E&xNSo+EV8 zT{UO5bh=1Y%oqBH0ytoYf?9i6<=1I@Sjx~ynGQqu=Aofb&W6|>%htXi<7DTYa|KSK zOg4{-!<$A$UL}=ltk^kX8=Ek=C~CEvje96=la!NV3Fn-Rj+d2*9dF6iJ|kO-TFyDE zcPWZ01cj(L7X9twGLPYRY~`FY@xY3TB1afr54AQov$K!H?TnXQurbpAE zIjTLNt=Cp+OSSphJZ*|Ln$@4ugx++ZC7cbm|`vO`b7%O`b7%O`b7%O`b7%P4{`kMKyVb4M&LDhhU7JQhU7H~A$igW+9uDKye7|>ye7|>ye7|>ye8&30G=UvO|p=@ zY=lcTdB)^5dB)^5dB)^5H8`*OEx$+O835SS7n!< zbSb;@wAsRr^6~i+UBC_HoMl*=*u!dOkU~Mtr5&t(eD)!!wkzhGrD#-ECTxUM327ZW zket0q(uUJ<&UGTAT-qIkx=TF|CUkge&dr>2t)L6U9RdZBE@I82^;=}R2MS{i8bQsG zI<%T}^feIzGt)K(W8Ymr8Au)ts{gZD3~8ZiJ#lTUpS}K`RE> zRxrm`p=iCK8 z<>f`3U=jg59@t)Y#@s6tzFC~JN*MNKG<(2>-@-~?7(G>(Lg{E`aL!7!D=#NghMm$6 zYLUMGe$Kg5c$T=83FU5DHzvO$ZBxfNcc2YfR%At>%{JD2D5*}`rkrzb7u%?XNqane z8>_!8sa)D-ALrbLHfz@8I4Z!W`D925%UiXGV>^fFO|o~J#5uR3&oHY6W+)xQl85SL z#~f}U=iCBrH8ot0u<4@~%-+V&jIy1Yh9S>Q1J2H8S@<$RGKZ*v4j z$G(bsPS%@goO81o*Dfr2R;*wJmt3#5a*k#lZB6RZImA$P@=rR+f9Aldp2;GE?` zpT&_xg_QqFxoqd08-)rOFh8Z;>eArnFb)&ZQy_hBW=9tpER7jan+4&Q z=p4s(&ZTz0IaCGg>NoVAvbWyLxm0KXyH1!@z;mBu^`=2)X@{U9>d+x5I9fm`TyiNp zdcO?{?oItt8MAeqbHB)ptQ{&uvXLEKF?>$&5g-Lti*_)+69ol!%iz82z=swy z>$!2sX3vw6nsZ);#(3RX!YinAqdxIib6gVVyi{aZgDyHzA0$vcU@Ra7`^4VS5fNkY zqw%o_M+Zms-LplySIhU2j0p%q~6AEP|8rTgt z>*mNT=|avG&^tAP!f>iz4w|BtJ#HT7vY<;)yg0yMw<`~Mghl_BEL#$rIhR?OnIc-F zry3I4g(I)@%@%Pk6Pi?1)MlayDlKdV_I&&@X_GCS%ZMg5HAU5Ea-=C`AIra0w_4`f zSi-ps)MHde;1cj=PRGp&De3|GJ)Lvu!3t5$2!EDh)v{WWI1tF5Y(>l%- zgC@1LLK7hLMl?TW`HE&R2RluWj)@* zxrT#IqKLN^&~87H_GJ#|8iu}9RM7enack=)g)&-0QYE>bb48Ktz|&_k~l={6rIbVX7;3?l)ls?&J`)p1$)E^*!~fst{Ns$ zA+elmuoYb*k0c<>z{d$devO*-Jdh;odl~1705=*U!Ahw5J?5XDYNU#Dg`*8sJf(2} zc>aGp6sV(!bA<`wL-Gun|3}tVb_18Ngf%}Qca0Tuu23|D`6j&F*@q0(?7-p_nV}oZ zxkB(f@~sGh)beD~Z{(48>qjxoq)ui3m+eXGhXGGpo;1H?I$`WIJfT0XyHESF<}G!* zs*diKiUT=JVJdi$(1Mo}IILsU9y^2svqCOew`Bq}$79K-rSpsTPX?U?TM!eVq`ohi z5yxuI<#xTAGci8V_l^3OM740EIM+Cc2VN&voCzSB2C8M=ku{u)oUQOth?*1lq-)7L zVqwu?(~3OZqudD2l_++&h6c!aE@g#!z+2UDE^@g-*380Cm)&doL}rjcdn8~gs;a6< z2PHo|s#E$>TRGPl{G28oT7wbdd1GDF5uq=Vw{z5TuF-wI1!@`m=%_Cw4LFG6D7-!v z*(~w(r#)!1s%1Ta5+|Q3d_H6v0dsseD_xKCrR1zhFB%Ix%Urqz!!HE^?n@0EATq^uMjFIX6k>KE0H4k^2;? zSKMy_n*6zhhhr`0nhiRgUNWh}=FuBj$LfSN1AW8Lrn7{nEpv=RO9A6}-EYHRV`on# z%iPTsoQoW&X}aB}SDTy*z?VHfXXfFs<)jxLNhr^z?XAk|@2;3&J@X0T=k%(Yx>e$aHc zagU*1U#ELmyF$~TzDjkNRNj?#$8ye9Do73aq>x*MoT~`EE-x>k1<;%3His>V zNGXsobilYTM7!q2*k383geVWhmy5{u|jI5yH9XCW!1U(tp5@-OEzu7+`$799KQSK!-vt zK)-M*qX7jd>~7Y2t9~(FLuR1`scuu*Puhpt?AAETw-$qWylIkgnxR6!Ubk0!ndVA$ zz3Mg+w%vh_QqD!bCEU9RGcfI!^fx4LN`p_%B}iH>4JxwDwz8gbokP0mJFqM6aNFR5 zmjP*Ib*%o_(B%UZ8MM9I$0R7S%KwZ1SSq)hIM+7u^{Vl(o`i9)5Aft^IM-Hmh)g4D zBj#_tG!*_ttT`#KWt?jZc$Jq6T`Ef&H{fQE_8xLAVS)GZ8p`tj7yqhc0LTe*5!%rD zrXffE)K2n1T3FD(@`}i?@pvY=nX0RWb%zwGqf%BIIM*hyf*a3~2v$gQ+Q9y-w7?bh zo9s-(rE)IvG0_5r#N#agfPD*V{z-pCDmirG-zc;aP7g@m-pUT#kR)?fnK;)5G{ImA z>+|>(iC>eXOS?RgbCCla2j6H==;u)klGm@6EbrAi})bCuCzMZ%5fZR$BQ>N?i( zTXG!*lgds9SE&MH>@#d3R&{_G;b&^oAj4PsM|AgS*CO()NW~iJ$4wyk=_I>j0p}v` z6!sdy_`D-}8(Z^!@FP!dUdv ziF+jvI9ClCz_Jt~HL{!qpCsF*Mydm+0rE?ckxTtS zvdacmKOx1&f{x&}v44DN-`$Ibkk}=H?m||}X4blQPzbAkCULLi6&;swF7i-;r7-nD z&nA84wWh@OC2;_vHaw=`2-XrhTR@ z*J`r-ZvM&iU*k8%2!m0dpc{^)xd`=8)vShT!vY-zTw9>89tRyD@_ep9XNOjgXbkrzumsEdgpK}AUqN`)$bYcuzr8zO1vK#Iqi0s93; zfc#k4JIP&-OpV>F!#Zs`d+wcN>4Jel(Pnh_O^_?(U~FM2B|7=(XCK#Q5L+b*d)2&n zsX@nSxHdh=y{frme*Cu{?@ul)2#IJZAt_=n%UhGQPwG1v&$VfLeJAeKa!M0uQwu>& zQjybfZRAKNs{nyi3|S4kS#x)Ct)z`(8`nk-bd0!-ZUB+Eu#M&KEP%pA;EhxX&gR+x zWQNs{Ln4Vhgoh9Ey@yYr8gMFyi&S82*t z+qpJ!Y2kV9$f-+{Kalok1=l9Jv`U1%w%~T{Q4fvY7KPu_ekANQjicapd@-ub(>kf< z+Q^edmNlshAp;?La`NlaXQ2&wvLtQxvYvz53YmY>#ZR zaIcNlYr!P_A$`zFu8o{ncxk}_g8+YcpT0~imbR(j+Q^BuX3aWB3_6S${u5I2cCIZ9 zWI74Zd-ul2!`^&Jw?{fcoRMn_MN5bnb%t90AQdsZ4>_;;log`lzoWsEw1Hx58zerA z6eASckd@^~PK&MfYzS#5k2*;YEDd<3?o9ej(rY+G+Q^rVUelE@JK7Vmfvqb@I%MvX z8usT~mXXfb){;DpGw-NQqX~4BaBbumf|3xfDG+B}$_^|`Tgy^DlN+;;6><={Ejv&T7ZkFdtu93V80uI|o z3Gb3aTNp!J^ZU$xt=!mO#kG;62rerKoe;PD+yGDZX0A;PzS~RFg9u~i#HWGAfO|yg>>mzR#ek-h4>DeKOYm4a}0QdAXS?Yv9IH|xm z^0>BWbh@HqL#8mRJwzl|K#c%Cqih}Yf89^03ED6elG1xG@J>K;|#YWqL! zLu@;&4_LJUGc9*oH0CnX9ma2siwuYK-|4684rq^RlQdVUzg5pu-9r}k4x1yUeWsDb zNEkJormv@HShv9h;*4G*6Hd{W{*e+VZ4lNz9SzFL@M}8Hu5BE((#9$qQwB>L#k5cB z<07rd0vp;kO~>+sS7DR+F;4cIp!Ph0F`0e6UY7;zx5kucDd&alQ_-rnwid16zunJT zS<0jc$qS5cpMnOkvT1nXoGouGRA)v=&LfV5_Q_~bO;XeA$t-4-lL}&_&xvoJgho|W z)CgA-$YnX4B5RP^o-1^O8n`beHn0PejVNK`!D?9hM6|4^pq4_pySJXt-79@oQhScT zirzf`wv=0?JHgcUY{&vxAM|rv*IXm?MEb_V+OvB3vBw9&Fxj%=TRCJ2iK z58OnqXVI$);-y`PY0pFhK_+kuxrNaLyMXkVQQ*cjBxfZQ`=a_B7Ksq-I(@Np&iBpv8%^%DR6W(iahq@ z6+}tjJH9;y&4ej}@$%)$?JVf-BprKUf_x^!+LO^t6j(&JtYeS;B$w#u_VEHALGbP$ z9MSmSl)Y2>C5oJO;i}WBz#{w8_AuK;)+em?fL)fiE#u6$nYv8L#-)aP^*`&U=*qPJ z&>TalE?#w-3eP-kb%eFA>U%Wu71pw6CKtp@Um>Qw7!BM@vFx@myu$q{G13-c?JIj( z;MjjtSeY=nGX1r;y@(7{@>&S~GvvnAviidq*~)fJp-c{9+6#N1$ApdX+h6C!N$0t^ zo!kuALIh6Oie8pV$%OXh!p|{*44JGuyezF{Xdo^vSU@n$Urmt-NkaS5z6Lnu>|#&f zEElxmcJe3mcHo%SP|l7rwRzyH#JA^zlej>I(*jMa^i`&|lS{OB<-lx59lFp}wgGy_ z-o6-&Dk_8kPC_)vAHK3oIt4N9i_pM5Ny5?h%;nlx*|9m;k`MnPypVSUPgGgDVw$DR@+FGnV!S_?kqIb@gX`_Vpxo9B{ zJiIWwSoG9IvVkAgK1b*UEnk$MfeUYBflRLtYo9G`0DtbNhyqqRUmquX)A;sTXeJK3 z;L=TY=EhMbi{!*@GZv}>=i6_$_1H44jR8LdEVtZi{>41Q)NE243#ssMhSBO6-M(4i zL>|5TBZGFZl;ncZQfV97z6lM=%c%iQpzB%EpGC3;BihS_E~5bgS2nS?Jm~4|8$qwF zB?bsI*~g}SkS=2|x_v`m7jXBmgB^H3eYAAo6WiCLL3K4aKof18eMsKFG7!c7!9ZEh zKJ;KeqP-LiDk|vt$V1u0DnnMvoa)q~uqPD-3GgtJ()Og;<`@cV9(tbNSAt$VwBu0mFucnA1bT(nn*v z7##f-OS!w)Gz>r&X~@)0`MCa|9Cr_hYniO&KPy8L(~vrwg8vn}bFDJQrKDtVi}}t1 zA>R$&gXHuOlfW7HPDSst)`t>OL=(4*s;I{$p`-*0Y^W#+lw& zDfB0k?_7fZ2=0o$H)TOPODaXLMbA&BwJiTfi*Yz*>!#zKvweQ&C4A>%JVQ7<^grY5 zw!%G)U*$Ps02neMl~VIiQBj$Xz>SiYgOYB&;t)Np z@M_ljaZDyV@b}O-)-gZGdSQYwlJ6v}2J$)g_ZEN;zoh?@m42V2i^&vGLNJdR=b6WG z-oPP0+GnrIY)kKy3S=*$5=PbKTUmNc=lOw202bB(aT;IiqHqSOqh`j;`` z>I;*c@qFiMaina;eI10xvqZcV582Kj@g_U~j8eLC{}dLfiSH!D0`da&^M+U+#YPZq z7%@f2n8eORYc#|6S+?<=tH7Ro-Tmy@xdma5u~q{x^T3zVG1CcZz~{c_@twtPJgh2! zb-4Ee)>B$|o!nVIdZYkv2l+>B*z?J%!X!@PJ6F18RknB?zvO)kd?m38}P<7_G+jelb)ewlqlLdDMc!BAdl`7J|3n&+p^SO3Moe zvmon;4uCz-GRq_-nFB4a+;;`(*~fRTzzjg`;2J>ofbHzSA^lvo=+C?gHg!{>o)mF9 zo^_sC_C7uwp0OOy5N{Itmyk36ZjC|BTK~xL+?f^lKw6dwV*y8HKbs6zl|Ak^kA+Cf zeV~CLXcTp@%Kn6Hi#5#hn)!%nopGxEd)*-|LKyOM&Nbxgs1Ru97DzmW7WEAXycqwD zeTvoJ5~qs+xJEXuS}2tBXcL9;&fz;N#CM372qIH2)ShIm&n(x)Ku6&}q^nZSGtvsu zlEimj1n%Vglc?lV|0;&Gv}=l3{_>=C@kp8`j0D0H%fMptU1nDBotrVA;*~+)5HUQa zl;v$SHnJT>Hp7Ut98wEFru2D1k%hQPe2rQCuK^pZ|L&o6;kZjira|YV;vuxM&hrq3 zv4ZhYj%lXbioSP1Zou!c&&Ppfs;CfB(_wrlFREm(V#Y1vJ2!^7_ap)#>a}t41`|ho z%kJDws|m|B9lBGQxq<{iJSktFE=Q!CkChiV38*YB&CU5dJBh;jn!|UlcMm%NQu{mI zd#!>4<(Fd(BQi1sI66p>a^Dr6kk5CPiBCubAcfl^OU=0d*c1e$da=Y5*v#ZROTh*< zudQ0z(c@c;pCaoJ*qcy=)JY)boo9(E2nM8Y9k>fOK7HI-=_Q3VtogrVs$*b-rIRki zB0bL-15{K=1F2e6_J?d0)^N*<<~^o7!*BWzblbI&>JL;cN6;8Uq3myOfar+ULeJvPUgMxR4BY99#K8u5$vOr#h0+NFGa17gGmS=A;EZq<$0bL zRj@d5F4@D5Twg#ICp=Q$Qjf{TA&wZDwDSnQ!mMxQJ9pEn!U+`jD1G9?j@?}1U`MS( zfj@P@w)_yja~Iwoiqf3fKftWjHuN8C-Q|Tibzqh=&#QtJmgiKyvkIJrOQXI%!Zl$Z zde(>jCG(w?;3C{DN%|K&M=M}3Env~r1(mS9(#fzRCF}d)ZXVyc6OX_mlI=uz#A%lQ zY!p1y$H;JjWt0M0SC%h@*~0wq5ZTX?DHB|7{Qw-c^c1(^_I;DXDSx{-Hqul6x*;o( zcKl=PE#5|1p^uaJ&TZgC+oep8;LhxQcJ%fVxFTTQ0%*i{B~rk5ZWTLOGox<;*}0dC zH_~m}4Xkx*qAmvemT>UL0H0?$oJW(Wfk6Hro6EWtAuAfw!^WA0P~GPUXRi{`$Ia@& z+zh@;BhCX5g(Rv(xKL}}Zm48urvtSud3T&X3Zev!d7ibdFpf+3E?8B9kt$W|&g1vY z47k7}QWW2XpjVt?s$~ifFhfU2Vl&PlV*>kp+Ia+VtXGZ8CGeef;JkhN-v0MT_2%O3 zto8nx8z73Q8CdC=(4*%OMTNzl$an6?iwl>6{e#QOnDz};`HUz(P|xYUtLOE6XDv7j z*NJ^E* zWU+3zpz*L`e7E?pvDbh%mb>Kqz=B^W*+yd;#Oj*@7B)@~n&NSmP>`z#zVi}c07@is z)p4Mtf|V|bTuLZ>7*`ToIK5MSS0JPL&b{cY5LMAPz%1qW!q-@(rWjV(1n`B_o^OnE zysij31=&?oRQ4Ne>#UOlGAx742IDV=m-UY!*zF0;!-(mf(lBjE1UHlK3KHfb(y?Y( zx@Zr^3vT%Lbb=SP${HRasM~ph6PUiE6Yk;76lhcig%T9p<;z+nD7i>%0aIBp25nrk)f<|?4X-B(%~2DF&$5XL7L*-A#_%5pL0~kRd8}{-Q z@YR}cdh9Aa+gMoa$NHJXcSQ-^jdU!MvQ^)>@@+ljIz5fG+?p`b_wI)9T|?2`+FGd) zBf=?V;&I=ZrSe^3{T~dfWJv($?FGTr=QBq~Io~DL1B&T04M}bE{16*YvbKun-VtM}_qeJbc)WJ(fX8OFFK#PX4vI!HZ-dMwZ zBOfY-UpzC7ws$JC;PohYfrE>(EM1}KvIzRw+hybmoJzf&oobE}SDeoy;kbzJ3ISWd zruJE*uvF^5ioA|h-ce{+L%R!{*9tcW7uxyJeAgiN3JjKthU3}^PqNmZ#-#Wx+<@op z3KlyqnL-3ARi)lW#WDcC{>%t*P$v31jTHPl(zHqy_$nJ;Z=Pr}N19fd-ZkAsp%r!$ z>*<+pWYKqprfANLVCU`$eNOYWnZ>k(W@)}UD}1u+9t#a%`woZZu(2(n<29c(h+hw( zujw0>f0z3&P0tXKHs#S?TC4jn@yG0qM|ufg+zW<>@egP8eW5pM;{bAC$X6I*(8@$8R`EZynUcF`=Rne`L09^ zJ5-)bLknnv`)?YQM#bGw!0yP634Uz234GUBh)Hd2m_scU6Lq5Pq<=dK&|bT!nh|O% zE$H3C1=A@r=$bODNx`223CBU%`%&`(CET%}?;3;WVCMp?bMNvb?8952OipR@{9K>F zEbkJ_8xcBhuTCYX#9LVNk}N;g4Yt@(=qMSy{T&75=(Oc`Hn!MxfPD8~ucA;!7Zr!Z zD4<+o-zXf-iDW3`bv$-etncd_s+wv-62f#pOTb5LRRf^pv3X-y-mfcTeb#$iG~X2~ z3`1nKWL#xnj|cq|Ib4HvjqFu+{W8bE+qDk&Mj6>qPb7GM&0vS$$6Y~E4AB@iEggy* zkhxpg(H-h}Lf^%N2H|u`l~~}h!0c|yilxwUZ)eLOzAKtlJpiKupKosmYwc3iR0{=li3UZ&W9#?4|bE_9Xit+t0QSZ7V4|N_I<1;zi@b6q&8hU;7p#Ls`}~^ zMarj&l+V0UQnFRw3{{}islJI&AgR8IQltbcQX&;8gS}DeaMV+MWAR23D4~j!5Jk!$ zMT%+w1<$9`rs^B}08%f-rbw|WQUa7HXMT`V>iSlS>KnbBB%$aODOyE}Mvg$DN>Az6vF^Y@A>DZ zlSlMpBWqr3)U)vU*=d?HV_0z3w8@(0?}Sh1Z_~4PXf$6BV!`vX;UE2<`1!?a;^*g2 zh@YR`Dt>;lRrq9=PtVR3zxl`_d^+EsmHoO}B(}}XesmQ1J_A+2rF`G^1*%ZLYx%_d z7Q%RLG&Jg)bT?@q(Ck(3cH>fpqeO65E}RjlaD~!@eLggyI7R(OEAxH!Nzp}zSaVo@ z@1q_*_wRn*z$futIq(_5%jHPucbHJci&r#hIjrTfT;H8Aj!k?Q6&@}wmii=-!)#i( zpQUUH)w6W%(iorLo;>eVj~HdHWxk!%f@!z0qxNwI_HpirF#mjKj%vQkU8i_Szv~Qs zqujBK9WWQ`)@34U6H!NLl%Dlh?o%9l`7SDAEEdr06$+$(E}Qn0??D;xif8oQXZOz^ zu4g8XeU!q7!h^)CD&I#N@gC`r43sN#gh`|q#X^qzH^y8b-<2lJpFG+Fm5^@tPkK>Q zuq)L)meQAZu-q$zY&Wl;6k1`5p%XQ5XkNK(|qM zvoA&iH;wDnFKtKeTsm2#NPqZC3q#bbHL5_1U2C%jd}8S`ziN8Jc+~KizEO9F_7=@` z$iQa}&F1Cd9K5_@5$4Fg=tYbp>PZ}A&4Jl|Tuwl>%oFBY?(9e5i6$-LPgqR%EI)kx zxbm1QN|9tHs7l3NRyrg|&pKWPrdadgsdTA)Z22I6S1N8Q=HM|@r(L=V)WLHPv9te7 z)U#d3ier4fsc`J(yJmwe(8Bw|ficJ^jj^rq^bU$`^Tp3RVTLcD3?w5p$W3NiD zm4xVkloa7wsjn)fJY>>EsL?a8)ZZLioGY^!aft>0UpvtDgoW*r{zRlt)0bpi7O!YnbSztiA=8bEFPbX&!*sl@)OxFaQh9RE7 z9EN}#&FLWFldVt(DUHq2=>VV0@Ut>-!6?cK#5fElK+AIM-Th5q2#V+t4gG+zA z&Z%wF+^>E}bvq^9?2VwPEvl>Sd8Lf!=V7;daT%ek!xTqC)5EfHv|6-yC)v&W~$LQ2)eg?vZ^IcSiyQW5}Mp#*W z{3UGhH;Zx*#c=M4v7*7{yAd0~cTquZ40T`nkr0KEVR=n3VtpTP?21&KyQ(ToYQmxD zS0!6BIWu7$9S~9}2@%O;e<`wquJfs2H+Cu%l=Po+1h!>%Y*ixnD z7b=|H#LgaBrDNAymPY%|bt~UR5E#Y9(qKa>iB`laSIyM3{DADmzP~*T&;{U%T~E?i zL|V_CdMA_(1qJ5&qxNU3D^;3>nlSZ;>ic!Obh!vq__wxEyG}bsbB+a_T``j71tkZ~ z)$p2U)RmTs{7!$51`H@}^+Ksg*IuO~IL(LzfAktSDjOy_(Z$ z;+J2~7e1Xiu?w$JGx%@DYTn6ZF)TP$^VSdI=Nk_TpKMuUaJsXtG5A)s@bAuN4+WQN zHD9VlMxTEzet!10@X21iGdPF-0un1d{G;jO=Ldtt&-Z>6KfAI0fXwEF@jfcd~*VYT4&Z4H^XT-;iIy_hmfYYh$KOmFqK?J}(W4_gNFR(5ufpw9Iu^pz? z(NOg0$++FjPW?M8gz7uhH_pjS#Cal}wb=a*%!1jq36df|nle;|+RK9GW+oyDaGD>{ zPS{E*w;B;}5}O8~l_#>BMbop-li-(=Vx!wh0j1ezhzn(?c$T+tZUSq#ZSY`0tNDI7 zMX>yaKJF65QBWTLkZ>CGoh4E!D6N|E9tx!WMRmQ`*^&F_`hH$;T*`M*fpkRCNUn&ko);SagH=%#|C;cdY~0>gv(ulA#nz{l&hg z3%lv5S%cWgaJ`N7{oBN+V^CoE#zFah?xz)e*IMB{aCq%=EimDUJ?~{-vJOZ$=|U_p zKL46u1TlhZgej1!SXS_O_(68Sj)cl{`Nh6ZEX7H#7P!jHmzJ&Z_pw&-VQ|PsTFA!cd{aevYgF1rs$X+3egWOCp6CEcA7o5iNF? z%6`W7t@Zx`KDT^qJ^|pLFAP0MlJC)eq4`MtzRE=w`;B@^BDfnYSRMwk#yezk_q7@cy#3f^w1@S#)_!jwUt7KMx1pi|hnrGLn3Gk#5H`oO(-I*l zAI~M?xN_|EfUa8==lklw0l7lp==LW8yATItpyh64&BwFjSm1}LzMn1VF5@C_-M)R8 z(7-)_^i|I+z4sySI zV-(Ho`{oaUnO)6}g^Zc$96ozUmR9riTGlduNT%lWZ1MBUF!A&AKgG|_J{Lbfaf+X( zt`a`kig`n_*xl2IWI3;!H)M>)ebu>3W&hmvi}hr{o0b#ir%VTpcNlKhU#q)PTc^2N zU8~wfihW@OE=;=hf>g*YyaU>t7DV=+YhC4sx&TkgRrIiQQ&QvZ08IBH9s4vR&QHGp zJcHd@u!hQ%_;-Y2rf{>Rv4YwOeq0)9_-+lD;x1LP!(tQJ7TCl(7L3!e@{Nh1{+UTg z9P3ttx7^3iP*=5y75s0m9|pgT?^eOWpo?F%M4h4j>SHW##stG^0A0X~K`3Q^MWaz> z27x)iUn1SCQN#6SI#;Ci>k8jjHe99q!5XeFsVfGRU)g#9O<~W5T1JxWU+O=Hbcb3x zUf>oqOeQPG19BeM;uTnFNLH-xXAD@{ml0oJ9|Gx40(ii&rKI03 zf*W*9)^$Qh8={u_Vc^2ivkzQxGL$+PPfdz@Xg#j|(t!`01;7=;vHEN7E(}PG`#Jra z6vK1g^l!;f29^l-fDFTaGZ8g*dDe1^#osI3FurRqx(SbBwS?=rDRGZBrk5DlKZXqw z&t85d|HYYRyOaN-N10{=`~R(gTLa1iQY^n(Ua>S-R$E4!zc)W;zS_LZJlymZ_JTUv z3x0A3!UP^4gFqMuJ5@6_UDLWr{5(8S_;lX#_L!yE8!FiGcgJLDPA?Tdzl;$++1Dq= zjj0cg$N@9W_v}<~a;D+n z2l)FLsN`ko?41W^&7kK1G$z{(hD%dT7=RjShaR?1T z;CXdSeR@ug=G88C?4el`*~jfB()>?;3%Q4ppfP&HBDgOS2wH_QX-SF=`^f8^OZ^!d)9gV*yvuPm{vJ4;KdD``?;c`6V%Nx-i@lM=` z*`uC2Ex4*wpQ!Brvwe?vrAL6+-E6wjxZ6;r-=>Sw8r9#aJ|S1t3*qqSc2hLjbWr9Ju+cHoVOA^s_3+Oz|O5tSaya6?*|zJmot=8P3RYYo@s_--6I zYWZ#vIz@VkB-Ww$bE0NB>v%G2x$nD@o$t1}cO}_=jrtb#s7#(2uY>pUF9j=v@!eKI zD5Sd{HmQ3IullFwX_N^`1t(RB*~JcA9Mc4OC|l}}Gaf|&)C zXT^%46@4LRzUScH3l#-7Lms4L#2Nl)p& zzV+)g^@_Gx#p+LKLT@_I67C$HU$jHZqV*+#tm~8FhM+)gj3zyGd`i}MO0x*Nn$>(- z+~EZMQ_ILe6-q{{Z9iN87w|q1XJ0TqV|>tXpWdmvQG17`Nqxv&GCG`#;JZhn|Kv21 zC`?qZxs?5JFms82N&|=WZZW?WPLe)115ObIrmZaRs})%+C2Ed-jQ~Rj`%3{H`$8{u zp+%zc3Uu7=e+4Mh`;ykPvvE1aK2IuQUc1EvT&dNCo0y>KsME1^m66@_cDQ&5@?E^6 z`EG)L!GRE1L47725SZ=)$Y$x871N+l5fT!-$d5h1v4!s@2$-@m`H>J++Ak-cM%lHK z{Pk(<=ewf>IhSe^+`Ag04{0*f1&kn{#~HCr-EIgNiPodb+`#)-{%o_3UEVZ3+IO~4 zrbEzIxiW>!Eimq6Yj4j?gck81^aF8w-2@B+RU*9wp|bO=XVcRAeb=GbuLiT0i0L6z z@#z8961Ci4)j62&j(}|8#pSYfYFy3{#5PoIUF_$H!Lg6;Cd?PSbZY+^W9lhkkNT$} z5C`2ICNh*weXu>ci6#GID#EKr7y26qjm{F`(H>ugtB(tqAZ;=vNyeG>N#e1?WQM~8t z62*JIE>V!L+ncT{&08c=Z$6nM*|Rp1DL6&&(xv-Xf9oie2yyn?S!wRQ%0Y zMM{E{(x10T?8Zn*eR+#S8LdberAUcaq{Q`6Jo6S$$M%stG8#{hRHVcxQlb?pBSeaq zyv5VQ6@TPC#g4`&O7S;C6)BXK=Ou5E*hMP-Xs{wBLXqM<%a5KD<~?tbNTG`CLKG?9 z5le_$u)=TZRHwb>6AFuRkHU{sr*|t-b}3S-yiw|e2UU6_36!0RlpTtc?TVCb11Lh? zA`Q^i0VK*>B+3>=N`)fjB1Ot(Ii(l3g&wp?PU_!yPnRoFHY!p!C{or-C=%Qj>Oz@> zB+F+$U8+b~r$||=NLkZMk>j>d7gqO@Ja_ohC5n_)ij-nS%1VJU0Jnv@P^9>yLPg37 zMaptT$};b`E!2gjia#n)q~t47mMBu{7Auk#DN+_HQWhvu=6k2q;a%r>CxJ3okupb- zGFy={%L@g#Ei~LSy^uu83`NRxMancqN}eZ0#BHH2O!XuQfC@yJqDYynNSUNa$(2#0 z=bzIPWh4Y8^mQRek&>-Q$x@_D=%;v`e@CVouc@o zWJSt&ManouN)lbRz#~#jI%ciW!N)j&~%8LBWkKxRG zHzl%CID*utu|B+#l@1-NW6{5@TJF2^X#(Fp9zGac=sU6`Y^9aPuVvqE3>Rxa`hFp0 z;k(D7*FuuCxlZuVkkQmj*F7IlHzzm{05{# zUm(3-14JmN9uGLTC3Fuf{c(OgtGs-^fo%v4@iWNCQNwo=8rd8znbKpEyxwbfwnq;91LU z=0{9-8gDlIT|ZYhNt>!kP>*-whTEc!K$2}Y<(A@pOy)Uld0VvARfVnUr)f8=;w(#7ZYl@HeCzmr(?DQeHk< z8oPANJo-u$m7ppnzHhEW`R)nA<{IW0*MG6dVs4BGVc%`dXZ<5 zq8~WoqxtSM_a%}HZvI@Zbtjuzk~K@focB}3#i{J)Z0}nC1r_&z`8LxP#;XlCLB);M z4%0-cHZkJ&w2`;%Wj&Ps{c%*Pkw>De2KOg1lf)jvo=^SdNtI zh}db#0%wjdrC}6DpN$&Eg67%hvgpD&u>yVo<9^EaJ)jrauHA$Jf@lf} ze_*uC%(@X7q%D?W-+6K{@dyG$ZsH+4AbHkXzArfFOTzhpbs@cBWmT;)cd@fC#_8FX zSD=dgtKWiqU_$ypVHnwBKK|^8`^1<4(wRQb8L4r{=?9!zfO0R_Hs?B zxBN;Kzy@!FR7h@1aN?k{yEB4rNE@Eq>ZR3x_t{Vsw@aJITx+n;3m7J2zptpOFg` zSw|=s9_NjiU*mVNb!$sT3qj?5nasW`1dIh$VaEtN5+%Lvm=xWn$pErTZSugv}3=yX*+{;Q5RM$8)`T;tOL$v}U&*uf@*voel?1gaH?+f!{Rs5*i z%7lVvu*OjB8_|}3%^EA`yO#-495z`p#Fa;)98g{q59s@Do5Xi7rQO!%ut}8xlEpJi zUPh=46>ji-Jm3x^v1aJ$@?JeJcBD+jmIgdJnj2&zYVywi+JP-=@1=dppWUKBn5oVmeY+L?9%UYTyp2 zA=nTT^eR?+3X2Kz!&5}E?oR5H7*g!GbL2z*ZTF7td^hFv2^p__iw8%hWAz{` zlAy`32PjlLztHf=OZjff@}oUKIvK}e=d}4Q;qb0(7MRHd=7?$JBmOCUa>5c4gohJ0 zKP3E+pC?)k-%TJ8at9>Vbvgoa)9@J@BCbH+-yP?Njo@5U1BZ0qgt6miXiT=2B{5q-^lP%s;r2e%+HE{QB5Z*?b4n@!0&Gt`COws^PjqtyG_oKqjTF=UY zXN9mM=i;woK~Kz2WH(-9_19UCy_=vR;FOY`U5>v#{t&e3v@5KBZr>DmNDvWnwa2P% z4sT>rL*1#9zP~-T!*#5xumo>EH2i*63OEABZOif<>5aT`1pNS;QX&+n6E-UE5R0Cb zrCW#GvNRF}RQ~i{>GA^CF`Z)w`T>TFBxws9z+=S^Bboi&!Q9xjzPcSP`d3tWs>3S% zP5M&(INdL%d4d1dY|{Kk{j)kseNEu4y5ZXC+E(3l#)*bEREJqwY1-srw`dYg->9Ec zol-B+y`cR>Yt&Zj3JiZ5w;LKvmyvlp3XW*tLSFpxvLwx!Z=ArS@ryAfL%HFnOq zDD64paJy=<%I>u7wr&nsX~{ItF=ZJ?8&Y+jXz$U~pn_9n@R*@OI#7);JHw>T;N$af zNYw@tQeZb> zR$zHc(_g2}3_Y|Kh!v^8BgjAz$iIYnzyDlu56_zE#J@yq#H`a}Q-n|F zb9bj*sdED#JW5GG-&x-dc)@bi{D!I1*kL%X{}6in1&vGnjOtO+*H?wZMRd|64E$(W zdi3eUJ?w0)I=c)<#59z@qirk&P>g>mm%fHSNtsx<6_A``*eL|9vYw}+Ygos;<$5;b z$0$E75oCIuq=YO_`_J*j@?EU**Qhb#E%{GRx=kIQS3gj&P7+Fl+@5AgygDrCK;M_> z5dNf25GT)FVAO~wG)PqR3)vR1;wK3mqP#q2M!)C-g7&h!D+Qm2qsDEM`MmsLlbsYX zBBaS6NRQeu!{zgE(tHsp1mr>D^%tK?6rz!Z5by#dY(SS-#^xa&R@*qW=Ub+Aka^p+069i(aWKVJR=>CPR~_A{4@}TC|AL5P%_I zf=DmC=m|W#{>qXhmRB34XUn4ej#B{i2jN6unM<(6*s+JB9%dsSo$H4okC7&b2>C$+ zIlW&`I7kJ5&%Ky9(oeU=15{-Ack5dL?^xb2zic{Y>^7XxzpFc=b!r}m+u{LI!dy5l zgb1MnvN;L5j8?LYQs0lbNl>w6E0_6hUErGXWH6ZGW>I1VA$Rsp%Mm|4 zO&j==LE_8HO4+Obmvi+xK>TK8(#D>KTI+vFRiWPm6d=D|WycyvUGE>7JS_-92f)RZ z1ha+}tO$%?&6lXJqAX6_iTWNI4~3&c8nLL*Y}-qFMpDN0X*xXoXZ$= zg)j)ddnyw6lL74b#q+4D@HFetI#zipf>K)l$nhQT3)ng*2@wD)PFl*RVP)X`{s|UR zVNaT2xvW_e=7^EF#MECUu4d6cM=e-~WQ7!*-w0PO1Nb_BX)mN0DTDb&RiM^()cROJ z11h#Vt4uLB7@c`Xb!QPau#G=COdNcP_nkGggQ zx=%||Z%M(*v<;2Ul3RRch}-;=gV9Uu5|RasO!xM}9W2kHiPNNI2q(qqzMhpx6!hl9 zD*FqzORTw;U(AQ;Y&6_NlCWPL#Ld0J7=S&*?3l+Hho-}bITt^dU3cBG=h#PEGR8%U zkAvb%<0fBWu%L%%7(fjXr#Xq`)nuHJw#m6dk2V$VHo&CJCSI~8Gp^8~No_A1fHqEN zd3)q+CSReIJ}Upnph>K;Hsd+=`<{$sH_{UI$rezp3Q@JF0w1yeXdh#%u=ZGI2Rs&F zGv8=hZB*-j&+v|BVYG-_1oYEiY2`B0^EEOrcHXY*rUvS&wQ)lmhWG>63z%#Ibr z*4z5Oo9YN+i*fFv%_c~0@&f?hZYDu@t9Y&U4 z5?dnq3NpZ2?kUXDIYJzI<|>K(o1_blaEFd9ULAX#^gF#n%f470d#UuhT!)73ToZdl z`rTZInvGi<`@HnK42O#Syf*eBYyVI#tz(DR#m+VNeRFe*CCgcwQXFWaf4*oO3}hrj zHvXT9|EJ^sS?<4?`jzao{+42U@t>TrF@Ll{SD^~5aLY7UO*7I&oL_kFlrQA!Gc0olY7DSD)gQi%b0lSF`qGDo$APg~~coAbnImb+7uw1<`_$h+iUAfeccv3wh z9fFB%D!_3;9dYf!c|Qb2p^xSr5As11<~<5>6@C^MQqYvIcAA1S5Ed8Cn`CK(jq}Om zXF}Gzmr=%@Ev$d>^S+!ph&=woBxeC@otzz?Lz0=ZYZxRWa(4BDWMs~+&XA1C*=2)d zY|gG=NJi)E@`7Ys&Mrqt#^>y!A^AFI=O{?N$=UfSBolIWc7sID+1Un?i8(vNAeof2 z(+85QoSk(c8J@F~r6}y5vttZ3&ow%WWixViP29O8dN3pS$c@z2XU{X)5~N0QpzJ4i zkdr8Ca~B(f6BkfweoHGl6+mxpX1R&NqO9&Nc%aiu{+QA zx0axI!g`_ZB+UzXIQL}hGUdz9Mp~8bqDOukd8_JvxUR*wK>J{K|aHJj>j}>}9%V+F|;})Y@b=UNWvQ z4l-KA7vd3drr2F&;bS-%b))jJ6C20f zugmS`rg5p9C;O1yqrIW_mW8`e7{2?p)=)BIKWdGu=a9;#LWGVS+y}_r@kC`K>jB1< z?phA6bkjgwfpxk9>GaL}8m)YOQ2h?+0Zt=HNs0$c*6u>VxMqXST(h2(Hy12;{v$n| zcSTD{l#g&)t44Y1E7V0~@(Sk&1)go@l(YNM$0)Z16_;SfOK54dLG#(|#8iPtVClG$ zok6D{rK}P+Fm$tq)8k~?8wZC321f>3gMl7vM~|_iN88b(?C6oi_5eGh&Pw+Ocr@*} zLd_1b1O`WF)z|%2x%L}wW-(zo7n;TNmQTvL?`MELEJmG3X@8Gc?$&yWLdnE?psNKJ zbtn#|&&>9;zH4RUi%__?TD1kL)?41#kvu#^*Apb^ssJxMhL}odH%>k(AC>-4T9nXJ zrMh|>V2<%d@0$eO)rn4UpuK5J=)1b8MX$;4XX(q14*zXFcbHs0N4KCm7!wFzMlZr9 zN4@dHmLXIuI20@?ov+arlrTYS+rRbUZ|vx=?db7#^tkDz*J-8nI@9V1bDU12NXld6 ztTZTKTbp5gQ)EtwHGt%8Vbh4$uHf#*_aDJHJ!LDaq-^DO6*BQuxtRzlde5mrKKTnrr;h|J3>I7vBqF$mvT#TnM!- z%xUHT(+ksa(|l99DbV;5N22qXMd`*9#(d)d@s+qhJV~8E^Xawp7qlO~C!`8a{6#*W z|Co2>E^~{xUYt97on6NEV?CJ)rcf@GbL3VsBOMca1dH_yR}6~{y$ub78^Ur_jHaLz zkk5kPVucI7zK_JO zaE{Z*7~LG;GJhe2yVRV%L)Dv;;QT?!(}6-5@s1Q?Nn*TkkP+rkP1FzM^#MyrpfE^d zQBtFYowPbTnu%-`k`&KKVIR_;vRrM!Q1w!-UKXltVf=+f%HZb0BCJ2bwWaVTOO)DD zt*IrHBw&AJ6`ask>9zT^InUhH>|(lNT59TVYGk}?TyGp|3>2S;yTq|#OT%kJiD8N% z$zTvp3$uk({t~~4@56g?x4AXkAg($48@rv2U`L@x)L(K8%9Ki2BXf({!AxU5VVcu# z=yG~BJ(fEV*PA!g0-Jy?D(gz0BQ-TFqomCm~3?6N|@*)IU^P>NG{9vH;JJ3D^zo zMZHYPrh4$Tb{Mma9gOwGYH_ESEp`;$V5Qh)m~7}|a2INYAB7yDv(SKioaWlF1$7$L zpy|k$+09I4Ix}wcUAl<=hEAd#@pZfo$6^6pK+Dl!6iWR=y#IvGvZ8^Z4u;X1xw@}~ zsY=0Z$>Y7asop`xtnS+a*KF0=h-{|z8(*}aNvo3xS32GUCOniTazZ)hEz{OJ>jOXN*F}D!}7NV55Ym`PqYV3 zLamTMouQOW!FvMMCz?f*4OS&_is6u{89c8IRZ1~EnTB)~y`COMTk-$!eyLJgD+NnK zh$_Ab^7KDo!*$Q(^qnIdea7-u3n|nTQ2ByWpkb0_K!w$ms+w5Vdc=CRn=R| zBV~8RMYuSZ%c&wI8cYvH|=75Bw-mN*`47_lG)1RTymd@1wLNusIS6tQ`Aa3?`UNF>dJ z6Y7)X;Dk^HrnB4X$N7{Lq)9BP4o!%}r^ga+OTuYV8JZA_PX=kJGT54sZ^i{rii1Vu zkY$_*{cay+G>d2PUfLgjgO*?;YHsQ)ml_{Y`^4qqa4}Xi8Lk;Nf-u?6;3`zZ6fsrk zDm3Ds@Fo0gz8@dJz2bi67ITBSNRDSOv1`~-Y%8YH*nzEspF^)5g0508Of5`Hjr)z? zGpm@-n5J|M^zp%R4jGW=RtKBTs4e&Xpyf4ZiKA01sueM{Eq3V)rh zXr|NGD>Ry5&*XS~h3Me5lRDG26QulmF;$O#7>U33x&V9E4ewIn<3w*7=!1UQ-BsrQZzR{kB z3;tBP)JdihsSQmC$9Gp0r-)3j&k6>2{YTV zK6y;HW{K^BgFk8eivz5*>YE>$EhaiRaSl`*dPvFV37gNE^Ub|sn{wB*(e#xm)cD+Z&^X1|4tm>pk%)anZ$q_VlOe+pCcF?1L9c2r zNc;tUA>Ws8#MN+{xlAsceaV)v)7WHIW-c;|sNb00j0b&_UO}gUD0~~Q!k=P;52{A% z&>+-I{S^l2ZBJ?|hvJ%j1htZ5@%rIfsvJ#_OU(HC&-%C(D#by?k6{$>=nR#J8oGv_NOeI~$Cq&_jXf0Kik4qT9lYWzY zJ?-kk)u)JMm=vO*@#&Lru7fex!IbM@E_RTZ6s9$@YV&s^;#MJv#J@sH73t6D0Q@K3 zhjY}Xt<czd5NLtuT+J*PXNYAE|h18O4v`Be-YW9`0){ntjC{ zU?;KhEX9;EIZP{tp^s6Q=v-5C;}+vXaR$srXM{|_N_V1N@D02g55-pW9F>5e(h1d- zzm+@7F47HYwKNpA%FoRu=IQ26=DMcqrd6gP#!<#-BNk7}$Ejt8`G(${p^gFJSv!4}T$<;f}m1(4O4Qw%2t#uv?3-%`GctzUm3^J|w5j9bLr}6rD=UT~;WZ?$q z1dW|^q5Z103W&=_=L8Hx-0L5Q*!QfsGEig0=l|Bzb6fR-S64bm>jmq;v&e;w&hhyA z87)ugu*%s>6Q*IATj?ByFU1w7$gp%KPzNZ|FVD@51Q&!ih5p-?jUU2Wx#wI7H=XOm z)rCF8Ds~8KVV*IEm|UhK<4j+pSJH#&U=S}3;%T@8c0yNCA&j0N>Tl|R+ao zu`=9&c&`nQP*(iar3S=uxW!vV9Xh!>d_JvQTOaPjLobK-b6FLBs5nn%9E5IMLzofS z-NaNNuU88HX!<2fyw=L@Oe;W1=ButwBzRO$etOTfveI*{!}@0Gy4IH3M&%35nlf~> ze2#ox=Fl)ym?X3k1pW+6h&}iQTqU=b8_ETU@E0`@giusjU&15ohjFG-fuc1fK@&DHuZz5pg&T`=Z~_k7xpFrFNn*s$?I{RdmHysMo5*`CJ#4SDcS+ zK{9h32Vu6YF72uau3AXCOmnm-`8Q)jk#lDCOF>Nem)opZPUbZsa6&!ldowq-ebGV?Fne&-^4&{gq?UA(*rZTw0KZ zUQPmO;pLP>1~_|0sEZDV)GuKmUoUjDDlfd9zA%!^g^tnqcylc!UoCWu#fN{^i7E3O z&9#wD@Zulwd->^nH{P53joZh4%QeC>T7#mIK<~zP z@OY*X-HGci_m{qxyrsX`TkKNy6V{n2V`frE%V(=UufM*tki0v@;QR#G{&uk7OH=gp zLNs|YH$4nrXsOfZ{?O@jPj&kACY?ULQKRV_PLvMZsG|ZQI&tl#&Rx-0rz`qsG`d3O ztVj>XS5E2Kw_54hw?GZz-62uA>(^l#A|vzDLr6H(rL4_QUyL*sbdpT=?O;_hN&06r zd9^e>LXnrJ@6|M&4rED_4zalUpY!{MU8Gu4*%YyQb%-=E3ry{(Y-0&^)Hsm@=k=+F1(C#MySrd?kz|uTt;yaM zaIvbFWbY0$V1v2fdDnXHKdlnnsKe(lN5hNZ%F%jy!&%KwRPq+~S;>(xP!^y2Q_ree zqkn_5Ejon5LKN)udUh7|kV>GoQ~U(J4KH!Oas?pHT0!_Y%KpH93_6%vW;@*wZ^kXr z$8;V#AsuDLGO-M%k4xd^m$1l8Gbfv6(?!!FQ$JH!!Mw) z>2GKPtIbv+QXVM16h>0paTU%){~&j|dqebt^qpXWZRJkfACw(`!Fu*RC^fyQhw^f1 z5$t{RSuN_EQz&=+wUkK(som@CT&JSAY8H$(ISg0*Kqen^io(^Gbh`Q%ov!Ss)0KU7 zx}vR4S0w55tpJ_A)k3H59?_xkr=7yd(QG+`BwPz< zPIil(u{eyR5FM)cW zB3&N$?!O?Lh3!w7(;Jxyr>1FppZaV&(@bg_X;IIwz$4x_JFJy z4U)bbUvb655QK2%%3xqJ)Ur;@^#uqK{N zA;af8M{t#CP+zR}<8rfh!8i2MemPE2tsuXX^dDrXFB$G(bpgqbo3FNas3u=>#oK|u zHR%%4ErOdl74X$0{VOSiJ9-EBL9YsOEq8hb;PuF?Q3)*>KsRZ73U1TS(2@(JOY_(u z5cEbG{ zZRf*nxd->Fw4EDw?huq+Nwz15iQLi^aPLeq8;K#@^=t6pX~fZ2jOA9jg0ZTQY;O}8 z#+?I`2|(=Pd995nVC+WOb-a-wm>!LBQj@lKx=p_ z2DaVRi4ol1T4=yunl#5%^@say&?}td)%Pycwj50^&bJL zJ@tCI{!?C8Es6g}o9=>X41Q>QH&1_?m-XMh&8r7*Yaqki-eJSlhiJ9dl<4JZ4(ZLP za4RlyMqXLik&2;~h|q(2;d{&muCAe#ffHuofw(>@!)y5~q644A8;~QYAb;RGFfHgm zNyOsI} z8rV`Nzt-IPBXGZjB(!yl&((BQ$=tna7u&)Ki7TcFpu4)-k_gE@Ce}#u|w+2I#?tu0>Jc?U2 z9GcyTBsg0m__^JI9d{$;`MqFjfH6gHB9(t9gm6XUgp;5GJol^fIE)q(%SlTtSM(J; zYd+{^>PO&;sljee7z~jy5`)DWUOjq;WjuyMsI>Pg+*}-Z$g1z$ed8WFiwFP z1y3bi64YP61n^VjNnvUfe0Ow+%*kjIL7Tpy$mHEpbBy+o?Q`4{xm&X!a~pBI?H0jo zaxlASdITtd=fEWB9)#DFl5^|Yv~o1hr_5U8^b6WcEPr$l<2E&g;>84Ixd-vbk|@~< zd!5u}X1kEURd>3{SI;$6oI#;;$1HWByp&h7E7iNtutIVQKlxwxV(Q^G}1|D)I!pto~L z^%Hr+Pll;rW2c0J!UTQ_pTPaY9pJvPJ;n8>yRcJXg7dE7n4n zZ3v-xj%mx)t&gN_eORL9om}k_xi1XGQ|*z7;&yV`Tu080tzmbuli5z{o)h0^I@9-9 zcX|r*qcENJ5CyED2WT4ZLgGr2tRbMWjRtXZCDVW|W@_maCI_^9dyxrGMY~ZdZfN-2 z;4eH9T=}c~GCqxO%vFO@FTSTHpjh?I=K+gP3#M`cOwb7ypVqoXy7~`N80iy3{0F-F zkp}}^jbvk_k01Fm(#NP_xYnRDSa22Cr@(|w8h+{;LhgO$8iFf6)pM>hdXC3u?@AA8 z`L6UO(1}B7vg}{vLZV*khPz(s{-CDseZZAQt7duB zyNYim>(x|zrZJIO5k8wpSxcWdz15Z9XxU15w9hGqvCyAkV!4W2a8XD9y(bR`z?(Gq z+%+sW!!H-{TJ@vN8+TFu6W`-bg?afW!9ug?hc zh2Eemzejo%gg1<3deSwZa(@X%VLxNBX|cJ|{FUi7y@4J|x4@6^R{RwXMo-WVG!lgx z*vwhJVn<0Y#F7`Qk6@!O!D1n%P26D+`8eizB&3N3cZfGYZlcGeo7;?Uy+aQeV>*|t{M7I`9)sKb??@6+} zy<3>JxF~-uO#T~dw|LVV9Xi?CZ3Im!&&G%7gFvl7iAZ)k$rcyYM|tkd6v{>|0IfYZ z0{uj{qo;#quN8F$?P0rv*(zesn%O`_pVM?DLP$Mtkc#1=ydfPovxg& z)0MdzO$yJzUj6D^)w-drpjU3dG`2+jVTo>niaC)UjBD%arD~nEH5aeR)ly~0iS!7X z3{mU2d?wuw>(40BpRIT;O|GE5)S1$T{zu727wQ~Jm8MFa zq}NN|RpwU+-1zWOO1QFGC9?2X*ziQHUpt?*`R*=_6?HkM_WpP9K} zb@f7+Iw^l6Cz8s;Zec=Yp4>oPLrKPIw+av zJzf8)wjFkZpQ@qu46$;y_Th2zJCkjhn~RcM=GGm|SivA?sk&?8a^B6VOg`^6#q1CZ z!lQglXu%ir-+-CU1~vltSw>X2#j4BAaHJPF`xpweyY05u9Als&RdxR>;}PQwV;5sR z@s_w&943YuUK$P?zB8nN-S4KbM);B{1CNnR^csB!?9WkM)QNvg9I^u%a`bi($bM8! zlP+~~%669>@G6^IR#ZM2Zugb#CJs&9>yj0Z-8>iEadRN`UV z;TFZzM#2ZzB!kM0IQ#>L9JQfL#az764l~~xPg~z_Ce+0pE6{IM;iB4L3e(v&$Gn24 zZZV4OFSmURc?AzwhQD%~t0~lPsXEtdx++O!BE6m6lQcO2pT4c7%JrviH_(L!3B^d|aNzI-{xRBWR2IM+%+Yqww_*U9`Dwr6b=><(kH2t+RV5uH3A@ zP~|3_yJEghSIpDtTi@vPt*>>urm;h|3t-6D?$5Hk?2f<9nxLp3SpfvTnOWj@UP_K{50}u zQG!3l4M>KUyNg;!L*0ncBhful>E3d{CYF>(4~WHc!O;m)h4Xdq#Od#|$w?P~*Q-19 zpNJ)xKK#Bcaf=%8M$1>;1i8}=APH0LsY6%@gXtuUI)paye-js*dny@jb8jTzGUL@C*YNXxL zcTz8@IT0%QB#~|veS(#x6@5U-u8O_pTT}Vc_X9_Ta7_zRUehN4SJx#^K8l-wYg=iw z!dCTxWl>|PSgy0&=&sW@V0WYzxoXzw${#hF9IEM)f#p_;rMAxtcw?1iex{zKB**P1 zrL!&Z`X~OaU0ftt-!U#8SKTd6Q-_#53>KIJuytX4>A&e?^n8$^tu(@Aa*S-0uFB`| zr_u)WO!`U+Gruq&GEX(PH%qXMT?i_?M#dWBW@Dx?Tzn~(h||PmQ8rvOEHd;nGzQP6 zEy4&P0$fT;`CR@Z-hulCTo(FsO>jPLCeKF)*!ye|c-OKmnSYq0%y-C{x+2e4-C%;= zTEq7rjW?5waqhl~#|Zbanv0OH+S3~An%RG|$(bzoPzJQw5roZl4-wozCwpnwNrQb|+>2gxZO= zIl)LXcq_@|FB*{tMopd&CuSBf)3kIlG@)BCSW=q@h#=Xv3zm9atykDpSa~ z4Pt_zUmuVgN_Xk;?|v37B?%+jM2i&)I-zStR616FubS3cCa!v;(OlJQsU5|U*|5bX zg%jIEC=(~OnF7|E!qf;QeP!y$h@1IRlQ&Sc5Keob`W-%vjnvNPDP6X})U;ZK&e_AnCB>gP|= zEPh4}!_~nAS^YxDsdos8X#-qCqx}4FO?yp8fos}ndJCn%>Sr)$?6@#XOO;O&Js!a> zysbxLC9{pkE#xf1Hs3~-Q1AC(QZ8{z@c=O@#iNk}?t`1b@_z*7p}xqU(#DmaI)b7w zTBwev`MUEQ*^&VP1|_K;tvx`K&SrR6NKA%D2M+t7DpW|m&G6_7Z-|vAU5wxz3nqd# z`sPC`CXe*^%OnIbUX&2Q3?Ox~JyM+wE<%N{K^VczXZnI4)$jCfda^Pi+am(2qTN_T zHspBNl(eZHtBrI5D4O^@k1pUL;9=!(Puv9kuH@x;^w*xWLibGJ=6eJxrE@)O<}A}t z7~Y>zrbrWOJP+{%UmBxeQ+q)y5L<%zJPn3N#?!gMe69 zxGJm=K9!HjGhj{rmhZ^d0V~>4E}d)2K2T@YcM~hqiE#oG`Z93SaF(t}%cX&mpZO7} zo5vDcij8OT&{9OaQ(Uj=^U>++sliHhsmFQHE^P6z;3IAHw6w0C9yp_?`<7`bvm4ZQ zBw>nUVyAw(!kZB0_kQe)Jv-dcZ7$Ab-n6}~*R zg^_G8^$5fHyR?)%`q@KOXi-0_g2C|*crV{b4ZsRp=JEO#Y!uoeS5-DouS-0O8g}so zy?88(nd8h%rVHZ&E*Ayho8ymv$Gh-&9EWLi0{wte;XO+|o#&+R>IxEti}sW4MGe9F zwgcv^8+AR$>35r)Jh$TwO%;G&V`ck~4f_eo#DfiCyrV5J{^1gauiA=B8ln`?1j2j5 ze)z33mh?g@k)}z>l5D;RlUhGe_|}@Xm`0c)K-4ca<{CdTI*7lBi^cw86T>~jR>Md` zr0`leEKCgV-Q-;Gyp*m~~~$o8&YQTW&-Emgiv^_s+zFF)~GQtai4 zyujcBF)+2-))IVDmIzHzV+aGPL;SJ>6Rn_EI|}ytH10d9mg~aRXDirs>~J=WdBq%I zX23_TN8bWp`eAe^eu)p`?{JFx@IKnZ$?h3}R$QA)(ua9Q<61D8%m@VYXb+vP{#d8) z`04Z=Us65>ToTg1_Kapo$1EsP`HUp2Y!j{Vk&)8_f5SDyH9}eXZQvM=ET0+}NtR9x z97_)72F4esc!C@wegU5A>nJhM@Rwmf_@gHq9EHm;LOvG)_&@o5d=B59`;~W~))}9{ z@6gfc%oT!rODOxA{fV6o3Vd(oKC=^yiftH)zD{q1uiP5Eu5aQ^cpPqn>!4fUT00&k zAtzX1{x7a~BrwB+Q?)N5SGE)OV0%c`=K3z=(il%Gu6z!TB@q@SGB;u+SVTs6hT&CZ z3ZzXiSun;kiu8qxa&C;LD#5Cs;`}&IPsA7xQJ~RILmTv|T&;@OMthFJCJTAu=o#@I zX6}GLvyOoSF-LA?M>JPf=0to(XQ}(x4%AA<)Q$SZ+|{%f#mZ+uZhLGz02|L9#wOs! ze^C5h`~=LePYs7)ne7Q%r9Xrc;Rm6Y;0s@_l%K`-=KZ+8xWn9Rt`FCY{Tq5^9^04o zM*_GSPB%S*>i=dAL&(-AOk?^tdJjDn7JD~*7Z+jgZ*UTJMAyMxa0E!|1LZ*JxpY*T z4U)2t`LTJQITz$oYcN|rovQs#`%1VHJcb8bEleet*ffNx$)qTy%cs_Rv=TMgdK!Ek zARq7Bp`}Xs_Ys8F6^YxA1hybQ&yENoF*y+eSvT0)oD>YQ3S?|fL~~r-Q!7A*4YG#d zJK*I4zX`tMPoU-yC3b+-8SGd9nQFgPKUy5>P0727-~eu3p$_3G4C;+9t9#d9p^t}$ z;o7;xu}*l>y33B?h-^9CHG(ua(=}Xa;}ky9q~#b6+!s+(a3H=1Yl3G2>6GUg&Y*#y z^Ml{?3w5BdBxa6hsM2VT=MKcs-+_$-ErZ_RIL~t&rvD*bj`_!F9QaNo>3q!Jrn8Tn z?>Wm@AH>oGuoIa-0egCxX{M>2ykDv-EugNF1|^=U4Z$XE#+Sf*auBwl7qD#31k+MO zT|6}xs@@G+e8cC*Jj2Q563--JISlTFaTh&78i6~!$X!d7pu?VbMN)R!GZEj|s-^t( z1@LuPO6S`&hNK_!j8>e^dV)WaR>`6ZP>ELJPJD3@3{4(9=Q~gZpxb%tuwN?x1veFu+v|VkjL8L#>O6gjw zAL!eD@eIPhg6RlS<;F$NA4Toy!q#GFovKJuGsN0LQK~~FQ(Gt!USN&mgxx}}9%~WI zfu-O#ypQA*sGUR&);aw}-xh=!()XP!*2Q*nf?@a#-_Rd@z-5F1j1c99ts zo^u$!F0!h^OW<4KWm8eJ7RsdCo>PZ$f5O57@?LO8LL*G=fw&UiR0o=dsWuzOp;r)I zG7f>K!ds6Np1^!>h7B@eH)&s`*Hjmmuci&*q^2c0K>8A{%}1p-4CuM(|vL)yoxKlnW9DxR8LCwl5A#g zQ;@MEtY(dcaDFn^p0&dK=>qFX6!9wgIE0Ye*My7ktj?u*Qu*c7& z>|%=1JSk_#CT!=$OiD2_o;f>4 zD3!nUfLO%XQ7Iyu12MIkO8<%!0g>QT@Ny2x*^vRY4IZ4+tZRs}y-6QeP;Kt$x4E8UTrFT-?pvxUi zG0J^&b_|7jKl`l5WQghuC7B<)4j?P`cN%HjF$C~o!v;=fLrB7EpSmQwhU-OAmNB5b z+&~W`F}qVtj9bo*!OFyYDGuPV2QSUQCmBSR-cKnfua-A567SzrsuZT6(W`9>(&{oT zoT(m^yjl87@;85Jx?}pt_@l9t;i9mDZ^K2hJ(wQ!4m=Zogbq@Vs2*wu@TDg^#gbwD z2YzP`^Rh&>3<+;(u}t1d-$h18087#Rif)4F-Pm zAru`zhItI!{vp(^1!>W6;K2`}(B|Y>!+{$=XhW($+3Gp4o%sG6=B70+PHTe7>Y`p$ zJOl;e@#ewrboJ6)l9uM_Lh9u8awFEfUN$V7lyCETwZOR0Uo&)*dvkk5U{eiwvbBDQ zwm(pM&FkeUsHU@6-G0W0`25Y7t0;kFF6|Xh{FlK+wZ4(TVLqVjnn=>K9AVl~!__9u z?G;YCE`Q6QM27lU5WdwR*XC1fYl_Nvei{`{@5RK=!8bAmG&-T+u6@uv#oW$pHk~&S zQy-JJvD&!Fm|+Zq&G#X3s@PtX3>ORw4ShkAP$O)9ry8YYSX`DtC_r=c6k@HiQClRb zp2z;QDwGIhse^Bj^2Et^J`Gc`YVIX@4!+UkCs*HU&0j%T`itLDjJHPU)E#%^G7!7Z@)3Rr&lAyk~=i!ylG2!h@`D&L`5J%(-rOGQkHZ?X@)lO970)I8Y>m*Iur z($^F{Lc7p6C=nT`GI@dAcXv5OypQ=t;~GEBmqKwHnHqy}wS%Va!!^EoZdIk8R&Li) zrTJ0cZJ=WXl_7V!241Ev={(udg}nMEH5}K1Pwa@)5n67C+Bntw)kbZiz~^+5|>NgZT$x zf~xFk)Bpvc_1K$kNE2bl2a2C!DNE8!IE~5kAI1z zWDje0LJet(Bgs$u1u7GVH~SVTY=!SDEwagmHc}w9CM1f2Kfepz<$nj)hff~+hAGxA z4&HBda7zn3LrK*WU(kvE;TuLuTSX5iLpnxBkk4C3$C0&v_y*&BN3=X;#UH-?O(QUo8#JB?<^JYIfcyDx=H2FOb6e0k{sQr*15N(MC&t|nQJDlmEf>)~>KL-o zFS#q+I@mEKaAaCux=dyk7#gX2`xMQIi@Etfv`?3g)u244!U2pOngCt4){sauW8TI( zYY}_xwi*&i>N-QBvU-C7))fwDuF{X&g~q81^(fHVseyfoYYe%VkA(HVt4;S5tNMf- z+}1TwBb3b>4Q{mNO{VlHH2Bcu(mKObGGDE>*Ls79z-T+1ia>re5gq)(If!uyL{C&l z?En&=?Z^sjBjyprfKDNk96&7-n}%k>kn(HITz6nPDjAM`H!#Cop+IU&Ly1}rSG61Z z<_CZ>u&&>7H&t!q$M4lOKBR%oL(sH6>QIFc7%j~6^Uk1-c+}RzNhr4YAuqK=619*b z{@F=hB&8(2X%ny=-w;*`Ux0b*iDuxk@ho==HUr&YTX++~9R{%hWJGpSQ&RYSQWSaU z)(1j!z%!kgCMVS+c{7q4lL7AUu$;+BziHlu3aQtpJ0{EC<>2a4t-NyCy^kLR{6ST? z$U}$lj3kg^wAy{&@hj=Gk_up3k>Qu-0c~!LM6kfgGu~@CU~Wl1Z}7zvUV90d}=MdTx(hD#ruuu?yeP@pc&LRO22+LhidqGxRLg zh85SQzW$(!P4u(>GFvf|uW6lhv&K)qySF5zcikh*>tA!(?+q;~glnQm>F@4faEa7d zaJ9EiS9|JoZHi9U!m$u4f7MfsW~v^;x|M!5a?JbJBe{3oqm-NX-S2@f!|(3dnh%xY zdfPn}lj&;fhg3s;NsW69E_P_9>W5B&`F*NtL!Sj63>fdnW6WjXT+$jMBX)ycs0FOI z1;$=RP{)FfVl*Gb-Q!lFT=6Vo<-JlaH5J{LY&cjRCC(RniJpc#h7E?{h7jSIZ~*4g zwh&(*z&>Rab^@Ega$s7|W4beL)G3@Ox0XJVvlv;BP7_;O&(=)kU*IFFow+d62IP(T z2;x-LBN#ODY6r=MJQ3P9&`2`1Eo=#&C3z+(&Ye8Ln<29mC@A_3@Em~f=C3r0gtqeB zsnx6uZ~rfgS8LC?nA~m$1?zm|c@Q(w>37yq^)Vz4>g6iHqm5dxjb({3eiTRz%~R*7iul>D{7WXvWuEIn`jAN!|U;AoQP#I zzo^+Z)wJ?Lo!b6>Yg$>ny;-m_VrR429kRf2D1kai2_s>b@*LKmEItt?u@l_)5VPgT zUStW|i}heCsDtkvE#P#C?&fRe<=|PijUIuYL73kp%%B#BBhU@B8V%J0t6PxA zY3(>tVgsk=177Gj2p6}~+PP`~S=Tml0)rF4?2vxEO9U<%tUE+k4W`51VTTuK7+KN| zSm}rF;G-vXIJJFbJi~nc4*u!63db^1J@5kf;c%BR8kaJ(Lu5X!IbJKyN4xAsc>UKJ zh1bOD=_+SEEqJJNiESd&Ffk-Y#*^TM%@fHXTV%L0B{?z!6+8GdX+mp?P!NLQLWB(e z7Nli{f5$re#J`P&rn#n%P4$h}50t^YqB~?(=8M^&_E4QO6*|*D$$*O=Xp~U&x$(NT zk5}{X-9N$C&>up{{?OAJ6R~{bpP>;>_=l6lNBwsy{6zm3i3T(t zOxgcX=#PKbS0~b^#2@@sYWPk3aNffG2>}mZbFm!79)V43q>BmHfSqa>c!rIq4(sCH z@)G}R`rB&NKPmN(*PKn2kg&i{z}E}>PVv&GIu!kr|H<#@I4lxArMVBWrZi7w!P~nO zlQ|Baqg1PbliER>M=Nbon;(i5W2nUxguK*euInv~&TH;UKFMny$o*Kh7aTg2+`Q)F zIkk5WS49wTHiE)+NYS$9&8Dwk*j!n^utlF5rVVvmQ;0YH6co7?C@FN9m|0*94E-T*$$gzLxoz z3509ypvS{P8-ZP^<8&;jo{vI+0F2%+T>)8r#A;OnO0D04JrP{<@vPZ;I`a!9_=vT! zNpsT2dmrejvWg_k4uDf&z;hoL@x^%o$j0OTRD1AJCk?WL8jvPs0bU;q?m|2MH*Dog z@fVndke*c0!%z#YpJZrna29R}8->yQOo&r#1UA6k+!Pqb_1Qb%WAruKhIL@B!R(RA z#4tR4o?b=|g44X-;FDOhE)Ru0eLI0AN!J3J66R_^fLJvf<|6_AgVG4XYH;|c<~%O02!QDyj1;Y? zRn)uvpc$Etg2FTzj*luc&NJ_zR>?=CxyHUmU-7ZHN6djy;|x(SYYkr+A`M75CCn50 z2tFXTD10{G9t=F!xYZD%9?t&5o?z#|l+~Dd$o$Amf~aW+`U+KH{>I!Ixl6qvT3fSz z)cLRZLrRyL{@b0K5Qo@67}+fY!??OHQJl`fz3+mDqVefn*Hc|paB25YHO z5*fIOR+h#FW+2A=JIpJUJ^DqE9liTSX+58-Iu0N4OSNwd(hWDYJ^F!D6=*zRe311q zfw81*eBd5^xYS6+wm@XdGKax_?jV*nq5kGu=8^IgX_V|r{Q=+V3^*AKgu{HE8;=@i z!y@SeUuhrog)U+PI6`HIVIpiQoWM_NgD^^n2cN%R_yT??AI>9iG+Dr%M*iq$^G0)= z+)0X;Zc_)jp5WJe4ZMB&vJK$?ngS*r!U?M+IDMu+Xsa*cS-2erv*}~7Uq61_?-IUs zm}EZd7eZb>f%j9_P&~g#U-1q;?YBYXB^Sz|cAE0n{lakNpQP}MW|01&6~YxaG@7fp z4lG)nypLLI2(Ip`Wsn_1n?>Lob2V;ML$-g`3{ILEq8Gl?M6c{lW9@n5O=nL|Ve$fB zXQ`rYBHGB{&UmU}7Bd3+I15`yLZ{Kq@Ke74#Ez+HiTh zD0(%7Gh1#wiGaz|DJYH%aS8%^?IKe4I3j{PehP5qlZY@p|Av;5l!igqN#&mr5z4JU zBepSWnGjsNU1L{XJdY5$b&OX~8cG1yi=b$dlpU1J;l>beyNBdu2X&|MRy`PN9n5-f z2VO-^P*Wu}CrFar#3<1Z3}4ZLKg2-%DOSNL1*JlLK@f|`OzQ)lF3nnhL1ondOUq>@qD;Ku{|S_fTWeQlom` zat|C_N^xhkhL7+ESsiCvb_IvQ*%}e(s>wXb9B-z~ucV368@Ut&m(C^!W4Upzv8&M; zOp*&^n{-Hule2Jr^8s{~cm?;gaY&}Fnv+Oca8Eb2#jU}N4Q|JZtF`BALGy3MSN_sc zCFF!97LLkLeR(y{8crhsqsPkHX<#5!j1^EdzgJ>2ACn5Rn z)+lb~6U{3lGvC#!80cm{ne?EC^>2#3tjK1;HEOu242J|I(nZWn5?C-Gw()pu6ds{vR4EE#4#DX`JUyD}0^tak`9j!{ zg}~`7$J9A6NVD-M)dd5*v1%>wQX~B}vkyR_ifz?^To4IHCR*?@STZ3cA5ThLLQoD^ zVus~--XxuZXznr!q2cxYz5Aa`}ncYq~Ezs+!tJ9h?ri&_5jzIbIkX!Xwa}n{04Xa@a#jW ziMc{;Jm^|g166}Awor1cr!AVP_#2z`Lnz*74Crl(CYRyD-MWF#Qas7%W{bf!Q+4xC zDY*W1w?#2`;>&Mi!?6_|Aqx0{VF6e)8-T#D zQus{p;~(%_`Aj~P`yaQLo5A~&xvWzL2N>yLA zle$NUCIMfxn}AJ6bvai>aMkCkKpLz4PWjdTq4?-eYL)o#0yR-SPj8okbIYv7Wg&2G z#WDPVB5CFILW$SokEW86V07LO^ag)})7m9)B`AjT6BgiHJO+P`KZcGUj+6E4jvyf2MRZW-WSUf0E%#d*FpV@Z27FW)J+&9{AfH_)7{;&H1NZEKT6>_z9;mhl?*13Z zq88oxFG4MVswFj=)1DEZAU+sZQ z{{gbXsYSp12La%sJ#fJuIByS>+XH3xz&U&1tO{g(c&g_adzRDoz|Z!;DSP0gJ#fMv zIQ}7!Wrg?s=|e;Xj@bi8?SUiqz+ro!)E+3Y2Mz&{^`B!+58AUFum|?r1N-cOz4pK! zdtmnmKo%S-OD$49Ks2D(9{AB7*kupwvP}{{^zNbAPEti~dF40t@W{Vh=2^2lDNK`S!p(dth#sJu=50$g>A#+XJ)g zfgkLFnfAc}*bYg1JjCaU8;5&QZTYF%-J&}x@ zGVFn`?1AC-z%U)?{9gX}(w=3gJut)`7;Fy=nm$7cRc1(GV^J3B1Sf^9Lg1==8KuJM zt*gODIRtzIN+Gzav*-fKs{a?W6j+`Pak&sm?F=fHmF!?Pm^qB<;!n}v)OGYNwaT1i zT4DO!_zZ#p2QYAQ0DX>L4&J0Ov=JS{A^0lZ!2BbhYb-C9hv|VR(gtaq)K+pa-!<>r zTTb19t$_YVw$}@bAah;Aq6kwD0_LymL2$0Crl6y%FH>4JSCNc^0Mt%fS$aUnZXmqu z9abpo*tL7iOVExlaeykbox%nvt)0UTA?{KpXrtd2U%yOaRlMtmjpk^x8RYj0slKiY zEv^@4#Wjz$hvS;xG)7Y65*Do@aE#$wCr!;YU*oNstMgXnX>C(z$1o3E>=njj2Pc7A z7>9tX*D*8;^*~-?eZy^T7X&mvpeNEY-v}4-J*jd&5A5px!a~C+I8rh!Rf5oovBnr9 z5`O}(&J1%ZPxZ#@#ZoPEzZ_w=*R*?WKX-2_Nc zFKrbAs>|olffTKv53y{Cj1+pm6?uRmu1UQC9tq?F;pulG7fw*?({bEAqaYm`^4{A3 z?JTkFG(mY`g6e2qs*Yt{=)H%b&E7$L@$BimMXbPCqVARVG#mjQk*@;x9it#@yQ6Q^ zZvt$8G}P=rHCLP7Hx(Lx*1e2-8<+Gh>^a8QJPKA^-p0K!QnImKm84{FM#rfjz9IH={Ln6R7Lbxsg#HvCUm~IsfwPmWX^k4m>Azq~>49vN zq@W=T`8CS%92MF}DBp*5NQt)}E{FhXYi;PO>#kg_90Voi^Xz7z;Pqs_XLd1jnONv% ze9(+LZ>oMBS@pbEKWZ@YAC?{@VLF`V9VXlv=e-qoebC+}E7t`rmGrbfgCL6PbDujb zJ_YR)-hZm<*GDrY{`h0cnI8}I?_X8>xfJCmstVIB)E4R2U8$mMS(XHqtKK!b%DW-KOUS31i3QC27TrN4Dv8@k#Fd7Co07 z9Sg-iVS3(Rb}tumFS#6+NhykBEHq+VH3LlpAk=wNHBEhyd6S6(4DvDK1>+XT>Vge_ z7!Da$8U`9V=r2ItItl3Izw6%9t$@XvtM+s4tAJ<-)ZEe>)Y#S2aXWAay3SAYRsc?Y z4RGCX9E=Au(-rrai{|6p@0`FbH2@lXm=mr|)>>9Ux3_Wh5r2@lw<)d$6{`) zsXT29ok=e<^-VDlTRyMyMdK;Q3x<%`UzPN}vlk=yMaEv#jnU^ZJCrZut?E4>xo7B; zuz-Z<`{;f3-SqByBNmf?bbmpU{(D?ue}&7`Gq`_hfI!Dpr_(95cVUt6GXxeba3b)D z_G9gb+C$oU=z_ha-2%X~7qn{x18&KnBa{{krN^|`zf>yamK7E00r?Y%N(4 z5&NRr7$xGz2*m?qzru?0h6IAVRHz>udlT05BA`TXp?PR*_&-@`uTNj$)tEyjJNQ9QG_32-5NKWJ?|tpcf@@w;`9jPZlzY}#FzZVbTpP0 zK&+`V$Tx2wwqa*nEPH8geO(-59mqMMT_4CnnpMkB;QOk60oKYQRgy}{ouH;kK$IP9 z?qK?iOQ1$d#so-$jv1acbmSHotdN(D;}{rRS^){sQGWp@O#M9lG+B`ToK~-gtCddd z=UDAV!&vxR$Sj{=0-+L=YwlwFi4Ni|l7u*ljgJ!cGVz0yoaq$+OQpOMnJvVr;$POb zjmdBoxo3PqfB-@n1Cmp)znwl{2*w|dVv3cNM`&MptM(_qDRR>zt@ z(mS=s^*Jz~ovKaH8mlx3IaP%TNx1rsQ_fR1ktPv~Zf{FMpoWyH5(3$^?~6~u1i2CR z+Eo#rj=elxj7Dbb5&1!{&$UzlF0EGcn z5q7G1jj@!;H1;$6j;(FA!3uqcZ(#nGnC^@5{V%N+WvZma!lW@Sii>oFE|B(+>e(^a zL;e&I1j)y9V-m@>1yHxxFgqragng0-g(g4?vR8-%GJB#q5!ME)T!G4;B&428+@cX< zvQ-fRQowFaGcN{M0##mGB(X%C?XxzJOgxkPxV!pMt!B;X|kEtnk;|; z^i4@hqBzf%AxV+K7b!_~Y7t6{J^z9D6iy9;ExwRHBxwi0*C`^TK)!x$LuyB2eJ4^)dM`|X)mgp>(j}Z;k(8!{>2E1Z za=Wm7`8ealzuAswiO<4>SYgDxgb=OJusk7!k;bZePQtYM^+^CHNg=AbBp(t`mo&_u z59JwElj>ENza^p%zhoQOby#5n!J2;z{%{J0rdn|x>{NYHB!8U(r`@Fd49r`puO;Es zo}`%5F2Rc33T6Q0Fb8-mfQP`XS0oQJz~*c>xQS$rSDuDZY4QsU$FEZKqa(pz2(c&U zbF5}zyX|XmWipgCpGFXN9_Fl5-A?W zwl@P#CY*&dP-BpPe6Hz;-$#CMnPiD42jGYpCgbA;50l+j?%;!^Gc!3wk`;vNZ?o6!VgmLWs!#Q~%|qhVM2v9*K$tYf4Tw~cCHpOW9zb{}Y|Kx2 z8V+Edp#r=F@>W)S5b2*0KaX@85axTC%# z1*#)DEXD8@1WBJe9A<3QPSJ*F{sc1IDkv4W0d8%JdNMRB|KJZ98Vo)8mHKC(vh1h& zP4zyMXG&DT+AVcX6jNm=kd#tQC)EExIaBc+b&2KACQ1=aw zE+Y)<^tZgRE*6>kn+!lOc*Qu$7-G0xXQ91hrY3UOq8hHp_945srUVOxTT<3(Im2kh zIeI=kOGIZ8^;=W=lKtCL&ak;e@W&{eR;&~~`_kB$vj zrnf^6c}N!C4b<7Nho_`cfwT)IMGeG@4auF#p)9^t!$LKylldby8mKu(6`v|2xtF zeJI;dA!6O?E_pL|AJM}lVoNML`w=me{jf!(clhcTb*{oi%3JzP#LP~nh);5g(}xQ$ zs`U4iQa%^9X)d|5kiThfTAj=)fZtd;O9afxZ5&PksgMh&021mUa2&h|)un34kh;_q%KOB%86|f5+C4z{ZFj0E zNe_=ovGA#+nFg1Hnuh$oB&H!h1Moph8}gr1USPun?0Z4tIX;|B{~-T7wM~j6$sgng z3EMx+Ujwu^0D7|fI!Vv^`@)tJ`Qz1lRsHYIR5L3SkxWMgaj)?1PexXF_jUp9!D)!4 z$FRMDZgGHFK?m;(xTc8Gke63_2a=phZ=9Q2nG>|MAJPkv2X87?c$cx{k(!m> zA;QR&-mmNHEBZ4fMng323|bUy6!W3}qpDuLhlQ!Sa^FBVw+yIZ_W^;kMwthNu*>XQ z=Cu#b#c(nA9F*!6hG^zPU(U57;jTNTH%B1{Yx1M$~NwD|JnSS zv7!zNW7LERT~xij)vP(6tglP$Mm2fXKFWi57<>08H?H*$5$;^=56FlcZ0``#)!aM& z!AV()OHqBmr*3*P80ps;-RFSPrp}=5!lma)LotpN$H?%M_z)X6 z879(y0~}_JG9S3=*TG*eg66c|JVyV6p$C#)z;S6V4lX9em$(@F9am%POd|nY`-kxZ z_}&WF%8MKHmHHv=W&|mf0!95MRAT!1EBoyv>wWVz0a^MXE}a zigWdBeMuTqkp_+ERIs0vg*TQO{S4O)?`Yudf$?FDitW(SkAp4O38)|z0Id95-FDq< zD6T2A$F*yr;q9rp0%g=$WJpUl-yU2TGg@(j`48@iSg4+EQI;u_!Qx*8j{Oog2Uchk z%w)s*V&8V>&`y`r!l=VV+?lqRo}ln@yQCGr<)w_iWb@{XP!ft??3PvJ=GC-l;r=ga zyo)lHTT!J>Ppuk~9>#O704&(Hw<5hvNr%)6G>7;#ZeFtOFn1-qi=wU2%opBOR93oWqXn0Acm+>~dVtPD+m! zOcT;E-d*1;h!xa(vxn$JDtqo%>Dly-^gKPbzAT+-;JN1&5sVj6HzcP~lOq4_nt$sX zo7b50%w0{FfgJb{9d)Rv+(Ll#6d@EGO-@j<>UzoQgX$m&p#RM(O}!a`0@pu6jfxO* zPbPoHv8Gof8+w|aymTfxi-8Z+<^G=HVO6?zO3AnrtTs9UF?LW<)xA$A_Xn55y4THT zjrVnP;F2p5u6RaxT467621?Q;KtT9abr29kgH@fNM*0>MjpLw6bxK*K9If;RPU3#n zZklL3ZVEP?0vFL8P_g@%RRACHhHHVB%u~!!W~J$6Mbw5=FR7z<+LQXSHwr}PU}4TC z&l(2ckDe_`;6Zvq_ZIi;cnTS-lPY)#fcVfZq<15gn?`pg9j6)cvp(ag z4OYXWkti@5GES-(+%A+bQ`(Wok&GH8`PCIT0wVK5M=f`8f&NUJ5)^fcCDOhKHpIwi z%JWo@|J}@(M@fa+moiF~<;pxo40Fy+p#bd30SKJ_;%1wyTq5hCylFNm>y=AzE_h1q z!GFP=Go3R%%|EYNtlnz8z!#~aja!YAcqJ};_iMVS4pJ{zN_YK_>~QrZ-BEM4 zeztC{F54Ie%-)x@R+9^R23o`vS*nP;skYL^O^D2O$!Ay=x~H(JJ^gNO@< z6aL^{e0Y&~CC^_vhK&Cvu3NqP^QTh~r?-ei;d1Z*e~wz=N*r@!U(c6k2f9`kAczt2DW@_E`nGMfSt|PHry3UW}yw zklo*UhjOO}gZ;gCzcdvzE=yb}_)XEsZmP602zIjmc4;C-B2d0cj(!sx&F>?49W81Y zdwiP*tKm)xD91ha!yX{6p25(_{<}1dHSC1^B{~%MV_azpSIGdK{AXhvA6JSjn@A;_ z8OUyFBqeuC#8&|wVDj{s@lU!H+9{mA+gW7APy7G_;I zs_-WPK%(4Ba&P;`x;_V}%!g)WAde-vK6%mn#W#@2Z3_PpLz3>4MzczPs=pbHTvUnV z=JNjGv`zjLaYgt`Cz1EBqA)}IkyES~6m6M}0P{ZnDqejjDtq%U{)4}lOWNaKEkQ;> zBG3{qh+Ho5xm%jba!bk4eOcjb)qZk#M@AxhaBF>C24hWArGgE3TY($&8GJHag}%S(OuZPKfD#4w&e zrCOm%B`?*ExF9wq*=@=!A(yv}h!mo?j=B(Mw~^aho=MsUhx_715i zbG1lNeG3-4CXn&tdp{$2m3n!W>v_=$1qqn|n$L^w>0(g$XpU(Dnv= z-%iaesGXY7n=bbR2Wilb1 zwLvfreqEvYMe!MvZSJJ|6h`gkx^Y!qvxZg8&hmp|tT#{AKO5bV0L+=IdN&Iw2LQik zFImY3HY-%fVzS;+izM!%Mei8vU(}5 z0|)SusTP(nzQ!xY*Nqd5y}<3gWtc)jH<;ap7gMq(XgU2oASPo#?~?}fnW0%B!p}Kb zW(Zw2J}_^AmOD=-Z;6W*-W-&5lLdeuHpa;kQ(?`z<4v#PqFG;=vp9n=tSk#KWEgEr zrBnFZv$A4@F|)JYFoDX@b z&LQS|W3jk65x2cr)1)Lf7D-924J0?+Aws^EBgNRWH){*&wl8b0^!ofA@hPn7JRH*wlD|bn`u|*NiE>-|< zv_F3zH?8yGQR-(j%|zJKf6dik3GAReV|?imXp(``FV)AG-_tjk7nX8(}>3ke|jmbt5~%#QHMdF?GxW zCW%ogPSNuH$Vk|q+w6fQdd@UZ;ZJ*^hqShl%D@D!rJL!Pq#ZBfCX2L7tT|OiV_Q5$ z@3fCnk^}1kGoLIQP+ykKq)-Ph;mQvbnlTzbxQ{GIB7`czHXu_V1Idl)YQrmrDOzsQaj zPB&%0t!4EmgoR&ZTN%z!`(QqZ%-_>rWcMZGzRHG;sr6qkg!qfu9mUBGt+Rn@Uz0WO!=*{%s9aCANH=Hgq_VsFuybP%mUpmeVBf)`KT^H`xB7!X5y~z zre+T;dLz`o1D0R`fX8q12be(pE#1$$sfwTBzujc2(a$&a;ymCPBGjlgRI!JQ*ZBoN zeQQ!aL4Y!&O-F-C5o)R zwCIA^>y6TMk>ghvO&4S@xC>S0 z!7>_qey{{W^DucenvLC5T5rh#G9Qx(RRchk?FV-7P-9m(r>!$AF=QHC8CS*2te@$9 z{W;(f6zDtS_F@yP+`9qn;ALP4djoirIs)qfxcfKM6V?9w4gPKZ3FwsksCpN|m|(6I z(v#U-xbl{AFYa!m*}oK@K=b3OX$9QKuVssvS>}(;)n4XOTpa$XY?cT5MQ82?x@sqqA7+@q zslvS$`@U0pHms!2SL;)9;GtXp5o&x697wH4FA`VlF^osCaJfQr7%b&}af?R~;kS6e zL?o?iqoCjFaX`zN+!$^fF8bPpjx-K?ql2{h-Ef7uz6b0F?}}ma+PfZem^M^ivA@L} zojW~}{!LxK%VX{~t~STw9?$esz{TQ1brgRKu(%q?Cb~kyB4?NdY6>3$Sag`y1I;c$ zecuJ-KYYC^epT$pk>{C)ob#MAN3n|5qEf0w$kCi2q09R@VUSzxd2qWdmPUMY>w}!b z@T43d!K6?>H$uemoI7wvNUt-Sq}{S9=l)PGi3KMJ>qSpi6Aw3)10fnyK__88ShZ{H zD|998%90hkyfTGveFu8o>&F%<1}Xp+OWm{d)I6=W!F2fuf0KV!RitiZikLNu-?;a< zU)WbPzlcs+QkY*g1!_N>+}H4j;exqPeTeC$eug>1HY*y~shan6Q+1Kr`?%9t4|^eB z>=&zmo7!2~0#_lkVY<)JO8ZK8A-Jsy5V)h6lc02gIU3rt6TQF+ATPb2Iaz>%$s-Jj z8J5u)t`9BCQ~LI%Uq4u#W6$r1l# z(xB>~m9Wkt?2i6q&_C99NJYBU$U0KV$1b*86zNRQmD?RD?Vh3n!Ij#hpk>Xrzr)EDe}IzpdvM+Z{>dJvUn`DH}j@OC$%~ZF|U$i?m#W?2ZIs zX-8XsNIq%ylkARoa=Mf4qEwP$c1Il9-`RFTdYfu@#FF)0YyxRX7^*W?j<-8v#Cpt{ z6i!@z9^SR8Hf9)EZ|*RWM$mJ5yCYgi?rOs&4lS*!a=P6SMTT~>T_MZoMe8&=U>l!j zAh7RRHYTDR+Fjd$v--4dp>C3{K$oG5(Dl%D&@tG{FKN#}180YJGf;^ZX(z)uN~Sha z>!)?ovYJ-SBF$utO_QmK)c9%KAj!C?Zc(3AA6CDseo6hTda-(nx=@`3BjcWG*qXw@ z$Yoq*9pQK4$h8K3h@Rk!m>bMzd~OWy&v)Wgs$1aAK7lXVI>?XLs+OsysYbxJb}S6i z&H%z=2ViNQG5=!z+I*Thrs}Qg3=ogsxo^31fYaO!3*~jN?FQhuc{`s?tYu?H*>1C1 z)`N{@`><1>z>v*u!YO7EdjZDn``NdZeUu(>nthx74x@IH@@eHFEOSN5Y}^*YKN9Bw zDc5b~cghPm1n%dGu%zKfQvM~Ey5x8Ct0|MI0@oDC3HhXH)|h1CJ$sA`G1Fhcv&Ynu zOS8vVN%EXAW#lZSot-nLqI@4W<#E}G8Gwu7p+O9W21hM{E z?J2Z+nQ}sgoRBUjq{#{W<%Co@Aq9lTY_e*S&#m zIl<=v;ekz3jpYHQ%_gbFTTbXMCwR#To^nDrIiahZQ0^h8bdeJ}%L$$2gpP88yPV)A zCv<2dJhVxwag|4LkrT{vf=Ny=$_WNJK`$ee*XU%F8m*k5krUK%0wj%(uYxK$;a^+0 zQi}1&CaH#%r(xs-`ApmNk1WQf`@-s_V_HNQ1<5hXLv!@Z8o8lZjoeVIMs7(}Be$fg znf`jNOLd5qXEp+?+xIy#{3q)d!q#h6UshQ7gY_aKOt13Ys1q1VuF@r}v!YyK9%!n8 zs$_&=Gu)M=>fY69wS^i75Ytxxy+2h|1%!pZxai%;{07e}<@EHBZKw=v1R|r)qD5KZ z(CV?8YRLM~T!S*X(Su~U6z&BWajt=F>_T*|g%IM#t3q&5k8 z^lo$`JKPIjAu~ti>a{%^J0S9`Sy{x#%jQO!hNvo~GIeZpg}4V55=D>{9sJgLDRB@sQFPw2gXvtF$yRocLTS978G& zvfgCIs9b}tPooZR=FQ7Ok*5!zOB%LYdWgl)_G#2gZ%FN51#?MR9?y|7TW)~1W1~jQ zEQ?s~6^tjAZ+MGT@X$thxAH99CKVvCkte&o3f;))8wE2-+0GE32XqysOU2ZLHgd>p z*)mdh%?1U7JkRO$BmJsVa(O_AszY+K$@!p-8Ib?I}PZF%}`!;#n0H?aAX7IxwMklQ--d~w4rnxdn zOv7F!6z4+o$$ganRfS8{r+qW{3u`s=6hG60|M%@Ga$N+iAVZKZd$n3jtvckbUDV#H zPONc@kNAZai}Zy{Y8vFJD-gw8+B$8HI>oqOeI~~J>O74Lc>x@WlR0TIKa&cyg3wRh zm*eW2ashT~ct;V-%mTh{h1nBO>oJ}{FfzN7k_vOLPPCQUewvwN(~IV(O`{dnwEUMP z^^KNyGkJ1mojF9j zvHRnRqxN#c?#)!|e}sm@TNDj`SWKMS2+N7L_<4MP-i%T6HvB540rTu4{C6&ZvrJdz z*MKZp2vx=oP@~_0<6$P?*jt!)0d2atY*-MvbR|ngqK0(~V6R*ek&xLd-{5|tTPS&@ zs9U)3(ui*3aovG1TZM%SPa$Rguw5#qBU$%sbSEk04T!_xkD5$fI*fY#g>57ave$uh zMH61@AnJINs={vPh3MjL5E#<;!kY473oU@Ed;5{nnRk`hYcQv{rg)nwi;FPUW`kE0 zS!?!!!aM5~FN|iqOj^mY@(szsvhc?9o+0oOIt*Q?{aq2M*Z_4%NYKgfS}#AZ<|m;g zyQfJ~#cX=ehiy+%xyx&S@L8=_cP(cg!fc|dV^xfFwe+9uUW=8i`D5vN6SqwR#A{9I znyO{k{bbKXMK#Ot#tkN=IUk-#|7MP|(_o{S3l#htq^~7!m>;yclb{W9LiwC>n6fkb zRnt~>3qV!+F|APlUCbl{pkG0@^va7AKDOk2gh4Zh@u6k#64lq#!oGmKgA9w&xc;Bi zGOE$0CU5o13*x>QZT_g8e39BYk~P73^UJ>gZxUA+TV zVWZ7~rn{z(O*N(|@JFwPw)Y#xxyCdY_+7^JZMk8H!At+6{-D0|Gip!F!mTjm=?%X_ zJ5{q(5wxjELv&sW_rWq{H%YVguMhwwFOAOYUmcUzUsw~HhfB`cNqKNk{gZN&LUari zLue%Ki+P;kQ5x>EKr?U=3&$>lBC^UQxF-p53C`)w zML;L;xN8s6f07z4t=DAac?z zcp7W$Dh;lOWZ`A^;BpOM$mt#DH!SeKfaY43ch~Ht`a_{-0~MwZjCMl?w_91p2I}r> z_rpXnmFH+|Gb{XH&pWB4eko0~VA790cI8rGRz+SgV~VhZz#l+#TzH5*ape+WXJuaa zW5n>4i-mnp=Jk1un7VS2aOmm0Xr^nsaaytX501Ms0V~*Ri{*vGB@H!-J?iC z#+tk%ZcGSK)#gETRGZhAH_XxhZ9XZ?sLlJ6dcI>f({dBGzAg`LL74s!Y@VXG70OWdE(xj2a-Yh&b2Hhely{7E#@WVj!!5&J!+iLh`b)oG zzZlxZci~8Hxh@$xi$@{HOw)3jkKhL-OQTVrge~qswShm)ujPmGE~;kL^Qt_RJ9~)v zlnY_AmG5z1aj(M>sDgPCxVP?#FBO7oLwXK0gG)?m@_A@Z6nka0BmudyiZqAjBujy7 zFg6R#0p6QU43ja*InhvZgfZ*-lDu|_uV0noU%w*7zgjPb`KwM*X*M-j6~@B;@#T=5 zd4euHr%59WPRzN)Jee}QJ|&;2Py^g9lzkQ(V{Mcl38;-Tk(AoFkgm)O_zJxXS9c1< z=6%gku*6_Jumx^|Xk=w=+}G5&vrn5+$MrYj!i4v?#u;@iv!71dy%5ho5bl!?cE+tB zAFc~cXh%D;D=tgmoAM##SH7xfCjAw{*f2cCwZzh{{YqKd_x)* z)YYTl`D^|PalpJm2h5gsZx-Fke`>oLre)#q-%W)f{YZF8dS_xSN%!lGi{yhUyDD4t zm_9mXui)T1@X=eEX@8PZnjR?_OVhW)uh5^0*C_E-kw{jLONVCkE0SjPyQS$NWZd}l zc%=?s^l7npNY+FWf7WSIdYE9Il>WPB8&_9g5xACYxRY?Gi1x>VTWua%3hfS#7=9`^ zBi+$^*d0pYyPpco5PQSXigA(M!9t)gYgT2?yl@hlJ$x3a{g4|#LV_avs_*pFl8~iT zaL~o>V1(ipn@RGwVw_`l09unKQ$ZGRbA90~@=6!j&Rcs~JF$Dp$;@wTe~{7sR-QGm^{yqA7*T_d)oIb!@4zZUS-cpAUx0*twV;7Bg;LRXNb#75nAfs zMQ5M>8M=wiEb03syjh{h-sjIAxIh>b0I46OX{yQvcIQ~4ZM6N=Cfa+# z?kvS;(D=suaB}%XQ5WL!F>52%;Q_$vh1(;u-OcVSk=iPQx-&wY5|WbM^VK$~BX4(( zAArd-8l@+f&pZl1*vFDNx>SD zlB?2@Ru>+geYIvk(ol$Yr}9yAx!sv7H8bt>2vVsJ|DN^$K4ujbFSSLB3!|>O!Fr!{ z4kZoCY`01GzVLNrkFh(42q%`?f*9Heh(A~;th03$>w(Z9Qn}mKLI*tJqS6CN*&b9{ z^e2TOltY#{Y(GoEG`lmKEOgrTOTjsI=Kzwj7f!UqWx{B8LLc`dbmO3U+bDsY}MWfm6a(CsB3(RJCKu0Q>W z=Rw<_)n~0nlQWgJ8B@v3%BSsc^K`G}q~d6|;iP50)vdbD`Z?=NAzcpHeiVhlhKY7( zvN)J+a06DFQGj)NxAhF`Od|IWOI?jIoo9C@LM7F9lB{=HjYekzT6)S9>NyNw$n9Km zxos`NS4avmT8fSOLlGxX67f9kh+AlGJ&l@&>X~W{M?nD#Y zn>K*G(=}CNcSe!aZMOST(m1;_k_>svR!4p$bQScmJ0k?++qTZq@?okRZg+-DO*0gO zDD@SM2dOw@-OD<|=y=A=*>1ZoW@SpYJ3|q(v@{RvM+EVCuJb5TaZfdv1f$5+>#bk2 zPK(g;lC5u>EPb}!=?x8jY2xX*GP|=oae38tO`L}&I?TP$8jBXiR%XDDTK5qPNlVN~ zsA2vwW<2?4Bdw<=`K8i!PK+;(ARNr>7(t?>eu>@Lm3+0rc2lb7XuH#ce6`Z{A9D6Z znzRc!vI=7b169vWu{%4HLr*^38ccR)Ct>?)8`oA)WYv*aOsMSCjs>Gh$lLMdZSs0d zBzJQ7Y1;$d0d;UAN1s6*Xj_{x2s@w(rKQw)8@7z2+v9;*jaRzrk^vmFIZ_latl!d! zxXc=}T&Qyy+sabb%1K%Wl$f>+nLCDM)keWOe@rbafaIJhqt-d(mX_OP7F3u@%fS?an~F#FFVD_R-Pk-07sw z(W5_c39?42?0xOd0O6?(HVc59d9(~BKtCcpj}7>p`O$iAfZf@b9D2b93z}`T5&Mv5 zHrhUr8hEnZ*_({4vVGmQS`V^2{YiGU?R^sR4vpK3bR}3BuyE*2SeAQ=U0az!*^AtR zo$pu23^l-$p3{%SZNiR8y~r6++8$zQ$5a}_$+|8hrjuRoT9>j;U!vLErs1KgL<9H` z!%JuYB+}JaSPL1}=}Jt2q6;zuW0iq+NwJKAHuheABq_V3T1C$O z2R`j)*_~!$UWeiQ#7bIXleDI6P6dlqyVCj{>of|hQf(3=sB^IovpWrKyMetAcAzbn zivbgSC2upS+idLvk9CX22&?s~M9T6&!)z1|nTOKdi7zTL@_ z!vnAmVSu0>DzZNtj0GK$rYyUY6I($Xk%G@AJ!T{P3A$1Cg76K&Si_a%pMhr#nE zq3kZ$$bM4c-VFFknqVXCPKMkbZ2Lr7S9;l<3gL?(Hh^%6<8qYUQBG1zF+J%5Z?b#a z9h9-f&|8G|Z_X&1MuxrA}5mgAC7ios$}=R1*> zUfo?uUChwIddCu}!HnR#TC$6}lhzAk4$u!7jgG|+cIdX3fCk=X97lO5gQ`aJyr^ z5c7l$M=o(e&agY?k+P|_gVe2~uDZ-x&N}9jxM^6SX^pgF?2b985iaDDr`fty_YcAn z*6K5$-8KtFC5e@6bI8aHNoIdjYJhZFy<RLuuX9K31W15sJ z7=F@nX5=~2^x;TwUerltg55Dyh>Ns^iR&RY2{hvq!jdSPht$`GQoDn)j#H+f>|{d> z7RtK%d{~5f^Al+HCe|^TJRQ@vPjEK7V-hKkwVfuTBe2F4*&P$fh&b%b_!#uBCJ3?d zHuZyL(mIWGj3+}AFpk7wg=QK@LKCr@(bE`*W7Sy73AmHsB-^KAwarkXqI}`Jd2KD3 zd(`DKv6ft}-BCgYS)qoM2sIa*U?ie8Y{5V?f~cE>Q{5!A*< zn=7Z<9l7Gzl=vFlgDs|0Mn@XXsD_c&Ff7a%zE;xpru9?t2uE2^$53+rruA>J{07GE z7$WvR)>h);S~!hZCTi>?c-Q~~7hrb`7L2#7zHOT+8fFkl`rUd_8l%X0AnE(Z!<EA%7mjx4e8c>wLZW9>%PC-iyq z!I4GJq3W6BsXNvybo9`(=VI0Y)n!jxBjuN=+f+vc>FR|=ns$Vto81wPiePdrN|WS= z*2|jT$@k+iMKDgo@UFC!4lA-{`0y^IWL3|dQV*g*LaSzhB^O&5bqpbk8FW4!vt~4K zFj5v3Rc44oYF1dtd2#p}5itm*pEqw_5k2r+)0`v5FRcdsNV_9Y900Ryq^WZuZq2WA zL8LazvmIk@z(qv>BH@^Vh2WL+QHP{OV20h%ubQ`gE7n+L*V!F?rG4kw<{nqXVPpnp z?&yOOO`lFTu2A9TcbXp9@o7&_u{(N`WG&@|D7T1u`J-O$WJk&H?xZ%n@Q8GbTVi+g zA{L{q6WLiZd?r0SOeHM~!RA5WO!%^Br_n)oc*@F(j7Dlg2v#%7x+0|?O?gvXDOHsS z_n^$x%{G>*#P0AV7PAd4B%b<~*&ROQ>(+wbXmX4Pf4c)*;x%LHckwq}e!X{bRC<_~8VZAJ@lZCZr0E)LSfJPQUEerFquu2x@ zWMQQ&%*w({h>W0kJnZ+6EPP*R)fDbhmJfWK-ZMuQ&X$D-$ii8&aHcGrAq%HJ9xm^h z_Bf$ue_1$H7EY0clV#x~SvXM^PIxR_9@R7cF+x<&I9WJW7LJjHqh(=WB|J71B4y!- z_F?GU_6%=NKsZbm4wZ%FT^%fYA0!J0%EAHd!abql+OuCf0uA?-g#mWFKje3^*AX%> z!U*|PXT=bOxx{o3P%DEBME|`$M3<*M3=e(dV5h_JHdPJx3jlPt0B3d#YztLnJ#Xl0 ztPF1)AnGiMt24OJMI`xRxVSp&mHv%cic(oui zy7pI_R04y4V=7*>?H#3cF1U+$xumUhv5hID;y2qaY4hmQm`oCH*6jS2Y1^yxU&LY6R-gbd9MP=+v zy(Q|V>0KM+5M|UTFp~7{zL?aSJp3d*v7$y?0{(?_6xFOVL^Z~UdCd~PIz96>Nm;H> zV>D^H-BvhdXk!#v_P0$^Ei)E1MuIuQQ9pyGIjY3zt${FFQuZ-hKwNs^tX64ij3DN_ zww0tcg6^5%gYVkfwDde1!^96l8Ll#R#8eV89-3{noeMo_!F5#gENHQ$WB9!>+weMY zizn#TXs>8PHKi~c@5ax7hG8o=P`OpfuqDhvrW0XEzf65)mqvgoqW6$5ZnGwlXQFch z*v1gD+pXA*d>oy-4oZZr#L&Rqgs4v#p!90&PIR79L+h0Rjb3ECXYp1^jWV#&lZ@~xZd05LZR|!~ z>0T@jH3$oaK$sO$ct085#&UtP*4~ZN@s4@QG-bH5htf@{WPgKp;W-#TI><{KM*U6i zM-M(!lqKiCEplaV3>N0hDO~#=)tmFx%!Ib9M~2{A=MIsW2xf5YMUODy%@;k2)%7Vw z+EAYy7O1K~y8Onp)3njFm^s1hWHvI30l}8ZlrWj50HI=^3D;uy)Q3Y2KeP$M^w!G~bKw$a8?@`v&R< z4e;Ulit0HyG@eR&A01s>&PXOtePHzT$lz(pLzAc2c1BP8A2)mAih#r@Gkj9Ts`{ur zU>Sd#`wl*aPjdT#`?ZOCnp*?};wh>kRW{nCy*e^!?UK)tZ@T1nAW=UQ^2q2{Nei7Xj>y=WeJ;Y!hFVb!V8AF(9-+oBaBu3L3O2WSel7TqyN(<0*s z3O1?_@$Y^7JC1+Qeh|Y)@jQlq&^#0W(RVZs*-)0+QLwfK)M=^MpW~}Ta#c#eyAfSV zZr2X{Yv`0B&I$D=S9UIQKyjL?5p2>|PH9wP8q=fQLy3ARS?^zHBq5G?o2057)yR?| zDY-kubyV*Gome{i=sG;BLNk__+zO?2MK`4puy;~sH-Ew&V(FhdUYc-WC(Ffo1x4oF zej}%nvi)o%c@+~@=W}v-+w>x-ulMXoDz5YJ+74A}o&U*YctiIcTtDVwPiQ-_7=_%7 z+!RL0%E~>YfoFhX=v1P`{4~#qR;T_XvZS*QJ^sXLBpXlL*v+frEW>iBYR-R{`qzq|5TV3>rs40I>eq_92xU8pH{{X$cd9H3QkKg2 z2_s#`rYou1NgY&Y+{T)P;ErQI)otTkM!0Z()MGN#Jw(r)8WT{(Y+8%vJJ_}_(WKLC zZ>g!42owSPjtCMK^&at5=W?bufH)2-Fli3Mf8B7EH;`oaauwV_u9Nbza))vO{1qD6 z&k^M}bG0T+-KZYR|II(otc5Z3$H35y1-9aAIt6oBQ_BT(q6sFd)!+YvrbDY`V z>~3aEKLfq@Bv6B1H*GL2e{^L~%awOf`7jJKy>!ZqF{P6x%$TcGCjN`WCb%M&wtcL4 zO3}2Z#L1Jfn%IAl)G;zr`COKdmeJ<2s;GaFxX6Ezlo9_TvElNPvSIR)vZ0`8+v;2{ zR$3j>jwDi4!Ey>0B&R3?L#J6O{+u(nbO!vQ-13Fl!VP~tAE(oW23bgh_qafv#e)AWp+TW=n%ccfN8;^0 zZZT`IkY{I`-qAmJ8IIVMUgI{>7oW^AO`^}(xu%8mxqGf@AD+ULd8SZ?bX#ufOup+n z&XtnR%?DZq{o~pK6U3N-UB-FR6rqbvKhUS=64Md-{BnuuYx>;36sR1Cov_U0OOuh& z%T3ehvvIj;7kw@&H&y7MtsE5>5mLO`kGy%wVhxV8go>oF$V#8pJ!msjP72rjzi0-M zzC3?OC+Y?-FeD~0^g%wMVa2Px+h-jS7ZHW5yZ?X9Is$6e#jCrDS$C1X79JN7L%Wv4 zCTIHCaM7?vxVg5AnQ7y=zzc*k<&~P%Py<1=BL9ot1;+8=X1k?nU(+G@o88-70pOAY z_-h}m91b-<+qB=(T-kJ_X&-(cZaQG0whzruA-Kvyqa*5y=BLGAlYo>?yj>k9HBCpf zO|&H>3cLO zy#6Um(-veW(62WgLdo}%BR}|eDaUAqXN$H%HPyvgnrczvIsl#TL*&C^RqV}cEKLUx zcu!30Y+ivXTE>qZGj7_Hpt4049 zTwm*&UPa7(_%wdgW^cB~Sq5Zh4L}9ABEb>bZ>UJ~b2R4%ty+$*pdlwR-i@;ELH0Cr zkoL6oqXk8K4%I)1D2}#DA`b2Ux*%j;jle-9#Rr@A(t*&{%4h;K+g@5N3usP^loe8g z(DAU=(o~NZ<&S!|c}4Q$-HVwQ8D}_(iHO@Lr8QQZ5(*Zyy6Ph*1g2`8hwnEkD zkU^UUqGOt$6)S!K<7Ay^*DbCx;;cZkK?-v_I-+f!OXn-uZ=;qvde^XwmjbHdUR3Se zl~ES2?klZLVn!h`aiNhfv#W7u66n3Z&%fLM|FG46ZaGFXokV+^oS18v=5*Df!-|fj zw#iM$*#E8Pa3(_>*>}2vG)J^tsyidE<9|=YFz(4*ct}@w_5|! zgvDa)_WQqM_y4G|>-N~#jf%q>1+@X{h34TkQXEg0Hl5N~rZ5xZX=9myYn3D74by8% zEOEWXB`z0B9EN@j{$G)~+Mf=SCqw^>B~IiZ+7<_qDQMeeG2hnEwHb3OWxgr86XhqK zw_p*e6L}4~HSV?el790{N#&UHvVHc!j`jH35J#D2`tQG4L+I|iQDi;nX3)G!{OUvI zK-%fCXJbDRnVCWFVfA?U`hU7)h)WR`i#=GNDo|i?;dqSyL2dVoE6K7)z43s-jf|PN i8aL#D9hd#PG3kE&s0Ao@@NmWaZ>#!{HF)@;$o~Q79nbUt diff --git a/data/scud_orion_ai.db-shm b/data/scud_orion_ai.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..ed9a00585a0606b9b5949d75c416215769a91573 GIT binary patch literal 32768 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 PD2(-9CVzqR;xF+3*=`p2 literal 0 HcmV?d00001 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1783921..e1ef71a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,37 +1,54 @@ # 📋 История изменений (CHANGELOG) -Все ключевые изменения архитектуры, инструментов и модулей проекта SCUD Orion AI фиксируются в данном файле. +Все ключевые изменения архитектуры, инструментов и модулей проекта SCUD Orion AI фиксируются в данном файле[cite: 6]. + +--- + +## [2026-08-18] — Двухфазный жизненный цикл сессий, двусторонний Diff и Topic Drift Guard + +### ✨ Добавлено +* **Двусторонний клиентский Diff (`core.js`)**: Реализован алгоритм сравнения черновика с baseline-промптом. Удаленные строки остаются на экране с красным зачеркиванием `[УДАЛЕНИЕ]`, а добавленные/измененные строки выделяются красной рамкой с авто-отступами. +* **Topic Drift Guard (`agent.py`)**: Внедрен двухуровневый контроль смены темы: + - Мгновенный сброс стейта и зачистка эфемерных сообщений при вызове сторонних инструментов (задачи, снапшоты, база). + - Мягкий счетчик отвлечений (`idle_turns` = 3) в свободном диалоге с выводом напоминания и кнопок из `tool_action_registry`. +* **Команда `context purge` в CLI (`db_cli.py`)**: Добавлен флаг `--all` для полной очистки таблицы `chat_messages` и сброса стейтов сессий. + +### 🔧 Изменено +* **`modules/web_api/llm/agent.py`**: + - Прямой просмотр промпта (`db_get_system_prompt`) переведен в прозрачный режим без блокировки строки ввода и без лишних кнопок. + - В инструмент `db_prompt_node_edit` добавлена передача исходного `baseline_prompt` для точного сопоставления правок в UI. +* **`modules/web_api/llm/core/fast_path.py`**: Оптимизирована зачистка эфемерных сообщений при кликах на кнопки фазы follow-up. --- ## [2026-08-17] — Внедрение CLI-инспекции контекста и переход на нативный Function Calling ### ✨ Добавлено -* **`scripts/db_cli.py`**: Добавлена команда `context` для инспекции истории сообщений `chat_messages` по сессиям с визуальным отображением флагов `[ЭФЕМЕРНОЕ]`. -* **`ROADMAP.md`**: Сформирована актуальная дорожная карта с планом отказа от регулярных выражений в пользу нативного Function Calling. +* **`scripts/db_cli.py`**: Добавлена команда `context` для инспекции истории сообщений `chat_messages` по сессиям с визуальным отображением флагов `[ЭФЕМЕРНОЕ]`[cite: 6]. +* **`ROADMAP.md`**: Сформирована актуальная дорожная карта с планом отказа от регулярных выражений в пользу нативного Function Calling[cite: 6]. ### 🔧 Изменено -* **`modules/web_api/llm/db_tools.py`**: Удалены устаревшие неиспользуемые функции и заглушки счетчиков простоя. -* **`modules/web_api/static/js/chat/core.js`**: Внедрена поддержка вызова инлайн-редактора черновика промпта прямо из окна диалога. +* **`modules/web_api/llm/db_tools.py`**: Удалены устаревшие неиспользуемые функции и заглушки счетчиков простоя[cite: 6]. +* **`modules/web_api/static/js/chat/core.js`**: Внедрена поддержка вызова инлайн-редактора черновика промпта прямо из окна диалога[cite: 6]. --- ## [2026-08-15] — Реляционная архитектура системного промпта ### ✨ Добавлено -* **`system_prompt_nodes`**: Создана таблица реляционных узлов промпта с индексацией по разделам и пунктам (`section_id`, `item_id`). -* **`db_prompt_node_edit`**: Добавлен инструмент точечного добавления, изменения и удаления пунктов системного промпта. -* **Инлайн-редактор**: Добавлен визуальный diff-просмотр изменений с подсветкой и кнопкой `[✏️ Редактировать]`. +* **`system_prompt_nodes`**: Создана таблица реляционных узлов промпта с индексацией по разделам и пунктам (`section_id`, `item_id`)[cite: 6]. +* **`db_prompt_node_edit`**: Добавлен инструмент точечного добавления, изменения и удаления пунктов системного промпта[cite: 6]. +* **Инлайн-редактор**: Добавлен визуальный diff-просмотр изменений с подсветкой и кнопкой `[✏️ Редактировать]`[cite: 6]. ### 🔧 Изменено -* **`modules/web_api/llm/agent.py`**: Генерация активного системного промпта переведена на динамическую сборку из реляционных узлов SQLite. -* **`modules/web_api/llm/core/fast_path.py`**: Обработка кнопок «Подтвердить» и «Отменить» переведена на атомарную запись узлов в базу данных. +* **`modules/web_api/llm/agent.py`**: Генерация активного системного промпта переведена на динамическую сборку из реляционных узлов SQLite[cite: 6]. +* **`modules/web_api/llm/core/fast_path.py`**: Обработка кнопок «Подтвердить» и «Отменить» переведена на атомарную запись узлов в базу данных[cite: 6]. --- ## [2026-08-10] — Механизм эфемерных сообщений и очистки диалога ### ✨ Добавлено -* **Эфемерные сообщения (`is_ephemeral`)**: Маркировка временных сервисных ответов и черновиков. -* **`db_purge_ephemeral_messages`**: Механизм физической зачистки временных записей из SQLite при завершении операций. -* **Интерактивные виджеты**: Поддержка карточек задач (`TASK_INTERACTIVE_CARD`) с кнопками быстрого действия. \ No newline at end of file +* **Эфемерные сообщения (`is_ephemeral`)**: Маркировка временных сервисных ответов и черновиков[cite: 6]. +* **`db_purge_ephemeral_messages`**: Механизм физической зачистки временных записей из SQLite при завершении операций[cite: 6]. +* **Интерактивные виджеты**: Поддержка карточек задач (`TASK_INTERACTIVE_CARD`) с кнопками быстрого действия[cite: 6]. \ No newline at end of file diff --git a/docs/development_roadmap.md b/docs/development_roadmap.md deleted file mode 100644 index 625fc0a..0000000 --- a/docs/development_roadmap.md +++ /dev/null @@ -1,97 +0,0 @@ -# 🗺️ ДОРОЖНАЯ КАРТА И АРХИТЕКТУРНЫЙ РОАДМАП ПРОЕКТА SCUD ORION AI - -## 📌 Зафиксированные вехи и концептуальные архитектурные решения - -Схема веток Gitea для ближайшего развития проекта: - -main (100% стабильный релиз) - │ - ├──► feature/tool-registry-db ──────┐ (Создание таблицы tool_action_registry в SQLite) - │ ▼ - ├───────────────────────────────► merge to main - │ - ├──► feature/interactive-buttons ───┐ (Подключение Да/Нет чипсов на фронтенде) - │ ▼ - ├───────────────────────────────► merge to main - │ - └──► feature/ephemeral-context ─────┐ (Внедрение флагов is_ephemeral для очистки памяти LLM) - ▼ - merge to main - - - - -### 1. Архитектура скользящего контекста и отслеживания смены темы (Intent / Topic Drift Tracking) -* **Проблема:** При работе с длинными операциями (редактирование промптов, мастера табелей, снапшотов) оператор может отвлекаться на сторонние вопросы или уточнения. Жесткий сброс сессии уничтожает черновики, а вечное хранение засоряет память LLM. -* **Решение (Сферическое / Фундаментальное):** - 1. **Семантическая оценка моделью:** Модель через системный контекст определяет, относится ли вопрос оператора к активному черновику/инструменту или разговор ушел в сторону (`topic_shift = true`). - 2. **Детерминированный счетчик в бэкенде:** Бэкенд фиксирует шаги отвлечения (`idle_turns`). Пока $N < 3$, оператор может свободно общаться, не теряя висящий контекст. - 3. **Вежливый перехват и выбор (Guard):** На 3-м шаге отвлечения система отвечает на текущий вопрос оператора и мягко напоминает о незавершенной транзакции кнопками `[Применить / Сохранить]` или `[Отменить и сбросить]`. - ---- - -## 🚀 Будущие модули и запланированный функционал (Backlog) - -### 2. Интерактивный модуль планирования и дорожных карт (Checklist & Task Planner Engine) -* **Концепция:** Специализированный инструмент для LLM-агента, позволяющий вести динамические проектные чек-листы и планы с интерактивными чекбоксами прямо в диалоге и базе данных. -* **Ключевые возможности:** - * Составление многоуровневых планов с чекбоксами (`[ ]` / `[x]`). - * Фиксация промежуточных комментариев и статусов выполнения по каждому пункту. - * Синхронизация задач плана с SQLite-таблицей `tasks`. - * Экспорт планов в Markdown/Excel и отображение на дашборде. - - -### 3. Изолированная среда выполнения и песочница кода (Code Execution Sandbox Engine) -* **Концепция:** Безопасный изолированный Docker-контейнер (или gVisor / Pyodide / nsjail окружение) для динамического выполнения кода, генерируемого моделью в процессе рассуждений и анализа данных. -* **Ключевые возможности:** - * Запуск сложных вычислений, агрегаций и статистического анализа данных СКУД на лету (Pandas / NumPy). - * Выполнение тестовых сценариев и валидация скриптов перед их сохранением/применением на проде. - * Полная изоляция от хост-системы: read-only доступ к копиям данных, ограничение памяти/CPU (cgroups), отсутствие доступа к чувствительным сетевым интерфейсам. - * Возврат результатов вычислений (stdout, stderr, артефакты, сгенерированные таблицы/графики) обратно в контекст модели. - - -### 1. Архитектура скользящего контекста и отслеживания смены темы (Intent / Topic Drift Tracking) -* **Статус:** `MVP Реализован и протестирован` (на базе инструментов системного промпта: `db_preview_prompt_merge`, `db_confirm_prompt_preview`, `db_cancel_prompt_preview`). -* **Концептуальное решение:** - 1. **Двухфазное состояние сессии:** Разделение жизненного цикла на фазу черновика (`PROMPT_PREVIEW`) и фазу интерактивного подтверждения/диалога (`PROMPT_FOLLOWUP`). - 2. **Эфемерный буфер:** Промежуточные экраны, подтверждения и тяжелые выводы помечаются `is_ephemeral = 1` и не засоряют постоянную историю диалога. - 3. **Детерминированный счетчик (Topic Drift):** Бэкенд инкрементирует `idle_turns` при отвлечении на сторонние темы. На шаге $N = 3$ срабатывает **Context Guard** с напоминанием и кнопками действий, а при $N > 3$ происходит автоматический сброс сессии и вызов `db_purge_ephemeral_messages`. - 4. **Fast-Path очистка:** Прямой перехват команд завершения (`нет, спасибо`, `закончить настройку`) моментально очищает эфемерный контекст. - ---- - -### 1.1. План масштабирования механизма эфемерного контекста на все инструменты (Rollout Roadmap) - -План: - -[1. REST API (main.py)] ────► Добавляем POST, PATCH, DELETE для /api/v1/tasks - │ -[2. Agent (agent.py)] ────► При db_get_tasks отдаем payload {type: "TASK_INTERACTIVE_CARD", tasks: [...]} - │ -[3. UI (chat.js / CSS)] ────► Рендерим карточку с табами [Все] [В работе] [В планах] [Завершенные] - -#### Этап 1. Модуль задач и бэклога (`tasks`) -* **Целевые инструменты:** `db_get_tasks`, `db_add_task`, `db_update_task_status`, `db_delete_task`. -* **Задачи реализации:** - * Перевод вывода объемных плоских списков задач в эфемерный режим (`is_ephemeral = 1`), чтобы история диалога не забивалась длинными перечнями. - * Реализация состояния сессии `TASKS_FOLLOWUP` после создания/изменения/удаления задачи. - * Интеграция с Context Guard: напоминание о завершении работы с задачами на 3-м шаге отвлечения. - * Автоочистка промежуточных диалоговых карточек задач при переключении на аналитику СКУД или общие вопросы. - -#### Этап 2. Модуль срезов и архива СКУД (`snapshots`) -* **Целевые инструменты:** `db_get_snapshots`, `db_delete_snapshots`. -* **Задачи реализации:** - * Маркировка объемных таблиц срезов и логов как эфемерных сообщений. - * Внедрение интерактивного подтверждения при удалении снапшотов по ID или по дате (`SNAPSHOTS_PREVIEW` / `SNAPSHOTS_FOLLOWUP`). - * Полная автоочистка временных таблиц срезов из памяти диалога после получения пользователем нужной информации. - -#### Этап 3. Модуль базы знаний, правил и аномалий (`kb & anomalies`) -* **Целевые инструменты:** `db_get_rules`, `db_get_anomalies`, `db_get_reference`. -* **Задачи реализации:** - * Вывод справочников команд, правил арбитража и списков аномалий исключительно во временный слой. - * Предотвращение «залипания» модели на старых правилах из истории: гарантия того, что LLM всегда запрашивает актуальные правила через Tool Call, а не читает их из контекста предыдущих сообщений. - -#### Этап 4. Декларативная унификация в `tool_action_registry` -* **Целевые изменения:** - * Расширение схемы `tool_action_registry` новыми декларативными полями: `followup_state_type`, `auto_purge_on_exit`, `guard_question_template`. - * Устранение жестких проверок названий функций в `agent.py`: перевод всей логики жизненного цикла сессий и очистки памяти на декларативные параметры из базы данных SQLite. \ No newline at end of file diff --git a/docs/in_progress/план_оптимизации_работы_с_контекстом.md b/docs/in_progress/план_оптимизации_работы_с_контекстом.md index bb04160..4587843 100644 --- a/docs/in_progress/план_оптимизации_работы_с_контекстом.md +++ b/docs/in_progress/план_оптимизации_работы_с_контекстом.md @@ -1,33 +1,72 @@ # План реализации ## 1. Очистка от регулярок и костылей (`tool_injector.py`) -- [ ] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов. -- [ ] Оставить в модуле только базовую санитарную очистку сырых тегов (``). +- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов[cite: 4]. +- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (``)[cite: 4]. --- ## 2. Настройка контекста и инструкций сессии (`agent.py`) -- [ ] Передать управление диалогом языковой модели через системный блок `role: "system"`. -- [ ] При активном состоянии `PROMPT_PREVIEW` передавать модели инструкцию: - - **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты. - - **Смена темы:** вежливо напомнить об открытом изменении и запросить решение. -- [ ] Обеспечить видимость эфемерных сообщений (`is_ephemeral = 1`) для модели во время активной работы с превью. +- [x] Передать управление диалогом языковой модели через системный блок `role: "system"`[cite: 4]. +- [x] При активном состоянии `PROMPT_PREVIEW` передавать модели инструкцию: + - **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты[cite: 4]. + - **Смена темы:** вежливо напомнить об открытом изменении и запросить решение[cite: 4]. +- [x] Обеспечить видимость эфемерных сообщений (`is_ephemeral = 1`) для модели во время активной работы с превью[cite: 4]. +- [x] Внедрить семантический Topic Drift Guard (`idle_turns` = 3) с вопросами и кнопками из `tool_action_registry`. +- [x] Реализовать детерминированный мгновенный сброс сессии и зачистку эфемерного контекста при вызове сторонних инструментов. --- ## 3. Очистка эфемерных сообщений при завершении (`fast_path.py`) -- [ ] Настроить удаление временных сообщений превью (`db_purge_ephemeral_messages`) строго в момент нажатия кнопок **«Подтвердить»** или **«Отменить»**. -- [ ] Сбрасывать состояние сессии в базе данных после фиксации решения. +- [x] Настроить удаление временных сообщений превью (`db_purge_ephemeral_messages`) строго в момент нажатия кнопок **«Подтвердить»** или **«Отменить»**[cite: 4]. +- [x] Сбрасывать состояние сессии в базе данных после фиксации решения[cite: 4]. +- [x] Внедрить прямое точечное применение изменений через `db_apply_prompt_node_action`[cite: 4]. +- [x] Добавить обработку фазы `PROMPT_FOLLOWUP` с кнопками завершения и очистки контекста. --- ## 4. Инлайн-редактор в окне диалога (`core.js`) -- [ ] Проверить работу блока ручного редактирования (`inline-prompt-editor-container`) с кнопками **«Сохранить правки»** и **«Свернуть»**. -- [ ] Обеспечить сохранение черновика через API (`/api/v1/chat/draft`) и отображение обновленного текста перед подтверждением. +- [x] Проверить работу блока ручного редактирования (`inline-prompt-editor-container`) с кнопками **«Сохранить правки»** и **«Свернуть»**[cite: 4]. +- [x] Обеспечить сохранение черновика через API (`/api/v1/chat/draft`) и отображение обновленного текста перед подтверждением[cite: 4]. +- [x] Реализовать двусторонний клиентский Diff-рендерер (одновременная подсветка добавленных строк и зачеркивание удаленных `[УДАЛЕНИЕ]`). +- [x] Добавить авто-форматирование и отступы подпунктов (`X.Y.`) при ручном сохранении черновика. --- -## 5. Тестирование и валидация -- [ ] **Нативные вызовы:** проверить добавление, редактирование и удаление пунктов через нативные вызовы модели. -- [ ] **Контекстные сценарии:** проверить поведение модели при смене темы диалога оператором. -- [ ] **UI и очистка:** проверить ручное редактирование через кнопку в окне чата и последующую очистку контекста. \ No newline at end of file +## 5. Тестирование и валидация системного промпта +- [x] **Нативные вызовы:** проверить добавление, редактирование и удаление пунктов через нативные вызовы модели (`db_prompt_node_edit`)[cite: 4]. +- [x] **Контекстные сценарии:** проверить поведение модели при смене темы диалога оператором (Guardrail)[cite: 4]. +- [x] **UI и очистка:** проверить ручное редактирование через кнопку в окне чата и последующую очистку контекста (`db_purge_ephemeral_messages`)[cite: 4]. + +--- + +## 6. Распространение архитектурного паттерна на модуль задач (`tasks`) +- [ ] **Масштабирование UI задач:** + - Увеличить размер окна/виджета просмотра и управления задачами (`TASK_INTERACTIVE_CARD`) по аналогии с окном редактирования системного промпта для удобного управления большим списком. +- [ ] **Интерактивное управление и превью:** + - Карточка предпросмотра параметров создаваемой задачи (`db_add_task`) с кнопками `[Подтвердить]`, `[Отменить]`, `[✏️ Редактировать]`. + - Подтверждение опасных операций (удаление задачи `db_delete_task`) с кнопками валидации. + - Follow-up фаза с выводом актуализированного списка задач из `tool_action_registry`. +- [ ] **Доменная консолидация:** + - Объединить `db_add_task`, `db_update_task_status`, `db_delete_task` в единый диспетчер `db_tasks_edit(action: ["ADD", "UPDATE", "DELETE"], ...)`[cite: 4]. + - Обновить `TOOLS_SCHEMA` в `schemas.py`[cite: 4]. + +--- + +## 7. Распространение на модуль снапшотов (`snapshots`) и правил (`rules`) +- [ ] **Консолидация модуля `snapshots`:** + - Интерактивное подтверждение массового удаления снапшотов за дату (`db_delete_snapshots`). + - Объединить операции удаления и управления срезами в единый `db_snapshots_edit(action: ["DELETE"], ...)`[cite: 4]. + - Авто-зачистка тяжелых таблиц снапшотов из контекста при переходе к другим темам. +- [ ] **Модуль правил арбитража (`rules`):** + - Редактирование правил базы знаний через единый паттерн узлов с подсветкой diff. + +--- + +## 8. Изолированная песочница кода (Code Execution Sandbox Engine) +- [ ] **Docker/gVisor контур:** + - Создание изолированного контейнера без доступа к внешней сети (network: none) с ограниченными лимитами по памяти и CPU (cgroups). + - Настройка безопасного монтирования только необходимых CSV/Parquet-файлов данных в режиме Read-Only. +- [ ] **Динамические Python/Pandas вычисления:** + - Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 1С на лету. + - Перехват stdout/stderr, сбор результатов расчетов и графиков с передачей в UI-чата. \ No newline at end of file diff --git a/modules/web_api/llm/agent.py b/modules/web_api/llm/agent.py index a62b435..a341b8b 100644 --- a/modules/web_api/llm/agent.py +++ b/modules/web_api/llm/agent.py @@ -3,15 +3,18 @@ FILE: modules/web_api/llm/agent.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: web_api / llm (Core Agent Coordinator) -ROLE: Оркестратор диалога, диспетчер реляционных узлов промпта и инструментов. +ROLE: Оркестратор диалога, диспетчер реляционных узлов промпта, + управление сессионными стейтами, Topic Drift Guard и очистка контекста. =============================================================================== """ -# --- [SECTION 1: SYSTEM PATHS & IMPORTS] --- # ANCHOR[SYS_IMPORTS] +# ============================================================================= +# БЛОК 1: ИМПОРТЫ И ИНИЦИАЛИЗАЦИЯ СИСТЕМНЫХ МОДУЛЕЙ +# ============================================================================= import sys -import re import json import logging +import re from typing import List, Dict, Any, Tuple, Optional from .db.connection import get_db_connection @@ -44,7 +47,6 @@ from .core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_ from .core.ollama_client import call_ollama_chat from .core.fast_path import handle_fast_path_intercept -# --- [SECTION 2: LOGGING CONFIGURATION] --- # ANCHOR[LOGGING_CONFIG] logger = logging.getLogger("SCUD_AGENT") logger.setLevel(logging.INFO) logger.propagate = False @@ -56,7 +58,9 @@ if not logger.handlers: logger.addHandler(handler) -# --- [SECTION 3: MAIN CHAT PROCESSING PIPELINE] --- # ANCHOR[CHAT_PROCESSOR] +# ============================================================================= +# БЛОК 2: ГЛАВНЫЙ КОНВЕЙЕР ОБРАБОТКИ СООБЩЕНИЙ ЧАТА +# ============================================================================= def process_chat_message( user_id: int, user_message: str, @@ -65,53 +69,91 @@ def process_chat_message( chat_history: List[Dict[str, Any]] = None, session_id: str = "web_session_main" ) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]: - """Главный конвейер диалога с поддержкой реляционного редактора промпта.""" + """ + Главная функция обработки сообщения: + 1. Очищает осиротевшие эфемерные сообщения. + 2. Перехватывает быстрые кнопки (Fast-Path). + 3. Собирает контекст и передает управление Ollama. + 4. Выполняет Tool Calls и управляет счетчиком Topic Drift. + """ logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}") - full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message + # 🧹 ГАРБАДЖ-КОЛЛЕКТОР: Если стейт сессии пуст, удаляем висящие эфемерные сообщения session_state = db_get_session_state(session_id) + if not session_state: + db_purge_ephemeral_messages(session_id) - # 1. Fast-Path перехват + full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message + + # Перехват нажатия кнопок («Подтвердить», «Отменить», «Завершить») без вызова LLM fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state) if fast_path_res: return fast_path_res - # 2. Сохранение пользовательского сообщения + # Сохранение входящего сообщения оператора is_user_ephemeral = 1 if session_state else 0 db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=is_user_ephemeral) db_history = db_get_chat_history(session_id, limit=20) - # 3. Сборка системного промпта + + # ========================================================================= + # БЛОК 3: ДИНАМИЧЕСКАЯ СБОРКА СИСТЕМНОГО ИНСТРУКТАЖА + # ========================================================================= dynamic_prompt_text = db_get_active_system_prompt() calendar_context = get_dynamic_calendar_context() user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость" + # Извлечение текущего состояния и счетчика отвлечений (idle_turns) + current_state_type = session_state.get("state_type") if session_state else None + state_data = session_state.get("data_json") or {} if session_state else {} + if not isinstance(state_data, dict): + state_data = {} + idle_turns = state_data.get("idle_turns", 0) + active_state_context = "" - if session_state: - state_type = session_state.get("state_type", "GENERAL") - active_state_context = f"\n[АКТИВНЫЙ РЕЖИМ СЕССИИ: {state_type}]\n" + if current_state_type == "PROMPT_PREVIEW": + active_state_context = ( + "\n[ВНИМАНИЕ: АКТИВНЫЙ РЕЖИМ ПРЕДПРОСМОТРА ПРОМПТА]\n" + "- Сейчас открыт предпросмотр изменения системного промпта.\n" + "- Если пользователь просит изменить, скорректировать формулировку или удалить пункт — вызови инструмент db_prompt_node_edit.\n" + "- Если пользователь переключился на другую тему — ответь на его вопрос кратко и по делу.\n" + ) + elif current_state_type == "PROMPT_FOLLOWUP": + active_state_context = ( + "\n[ВНИМАНИЕ: АКТИВНА СЕССИЯ РЕДАКТИРОВАНИЯ СИСТЕМНОГО ПРОМПТА]\n" + "- Оператор только что применил предыдущее изменение или запросил просмотр промпта.\n" + "- Любые команды вида 'добавь X.Y', 'удали X.Y', 'измени X.Y' ОЗНАЧАЮТ ПРОДОЛЖЕНИЕ РАБОТЫ С СИСТЕМНЫМ ПРОМПТОМ -> ВЫЗЫВАЙ db_prompt_node_edit.\n" + "- Если запрос оператора не ясен или относится к другой теме — ответь на него естественно.\n" + ) system_prompt_content = ( - f"Ты — интеллектуальный ассистент SCUD Orion AI. " - f"Ты управляешь задачами, аналитикой СКУД и системными настройками с помощью инструментов (tools).\n\n" - f"СТРОГИЕ ПРАВИЛА СТИЛЯ:\n" - f"- Запрещено использовать панибратские или шутливые обращения. Отвечай профессионально и строго по существу.\n\n" - f"[ОКРУЖЕНИЕ]\n" - f"- Пользователь: {user_info}\n" - f"- {calendar_context}\n" - f"{active_state_context}\n" - f"[ПРАВИЛА ИСПОЛЬЗОВАНИЯ ИНСТРУМЕНТОВ]\n" - f"1. Для любых изменений системного промпта ВСЕГДА вызывай db_prompt_node_edit(action=..., section_id=..., item_id=..., content=...). Абсолютно запрещено изменять промпт напрямую текстом.\n" - f" Пример: если просят 'добавь пункт 2.9 Тестовый пункт', ты ОБЯЗАН вызвать инструмент db_prompt_node_edit с аргументами: action='ADD', section_id=2, item_id=9, content='Тестовый пункт'.\n" - f"2. Для просмотра системного промпта ВСЕГДА вызывай db_get_system_prompt().\n" - f"3. Для просмотра задач ВСЕГДА вызывай db_get_tasks().\n\n" - f"[ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ]:\n{dynamic_prompt_text}" -) + f"Ты — интеллектуальный ассистент SCUD Orion AI. " + f"Ты управляешь задачами, аналитикой СКУД и системными настройками с помощью инструментов (tools).\n\n" + f"СТРОГИЕ ПРАВИЛА СТИЛЯ:\n" + f"- Запрещено использовать панибратские или шутливые обращения. Отвечай профессионально и строго по существу.\n\n" + f"[ОКРУЖЕНИЕ]\n" + f"- Пользователь: {user_info}\n" + f"- {calendar_context}\n" + f"{active_state_context}\n" + f"[ПРАВИЛА ИСПОЛЬЗОВАНИЯ ИНСТРУМЕНТОВ]\n" + f"1. Для ЛЮБЫХ операций с системным промптом ТЫ ОБЯЗАН СРАЗУ ВЫЗВАТЬ db_prompt_node_edit:\n" + f" - 'удали 2.9' / 'удалить пункт 2.9' -> ВЫЗОВ db_prompt_node_edit(action='DELETE', section_id=2, item_id=9, content='')\n" + f" - 'добавь 3.4 Текст' / 'добавить пункт 3.4' -> ВЫЗОВ db_prompt_node_edit(action='ADD', section_id=3, item_id=4, content='Текст')\n" + f" - 'измени 1.2 Текст' -> ВЫЗОВ db_prompt_node_edit(action='UPDATE', section_id=1, item_id=2, content='Текст')\n" + f" КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО отвечать текстом вроде 'Удален пункт... Напишите подтверждаю'. Только Function Call!\n" + f"2. Для просмотра системного промпта ВСЕГДА вызывай db_get_system_prompt().\n" + f"3. Для просмотра задач ВСЕГДА вызывай db_get_tasks().\n\n" + f"[ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ]:\n{dynamic_prompt_text}" + ) user_msg_object = {"role": "user", "content": full_user_content} - # --- [SECTION 4: OLLAMA INFERENCE & DISPATCH] --- # ANCHOR[TOOL_ROUTER] + + # ========================================================================= + # БЛОК 4: ВЫЗОВ НЕЙРОСЕТИ И МАРШРУТИЗАЦИЯ FUNCTION CALLING + # ========================================================================= try: + # Режим Vision (OCR) if image_b64: user_msg_object["images"] = [image_b64] messages = [ @@ -120,6 +162,7 @@ def process_chat_message( ] msg = call_ollama_chat(messages, is_vision=True) else: + # Текстовый диалог clean_db_history = [dict(m) for m in db_history] for m in clean_db_history: m.pop("images", None) @@ -130,20 +173,32 @@ def process_chat_message( tool_calls = msg.get("tool_calls", []) tool_calls = inject_tools_if_needed(user_message, raw_text_reply, tool_calls) + # Исполнение вызванных моделью инструментов if tool_calls: logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})") messages.append(msg) - tool_names_called = [] + # ----------------------------------------------------------------- + # TOPIC DRIFT GUARD ДЛЯ ИНСТРУМЕНТОВ: + # Если вызваны сторонние инструменты (снапшоты, задачи, статы) + # — мгновенно закрываем сессию и вычищаем эфемерный контекст + # ----------------------------------------------------------------- + is_prompt_tool = any(t["function"]["name"] in ["db_prompt_node_edit", "db_get_system_prompt", "db_get_system_prompts"] for t in tool_calls) + if not is_prompt_tool and session_state: + logger.info(f"Смена темы на инструмент {tool_calls[0]['function']['name']}. Закрываем сессию и очищаем эфемерный контекст.") + db_clear_session_state(session_id) + db_purge_ephemeral_messages(session_id) + session_state = None + for tool in tool_calls: fn_name = tool["function"]["name"] fn_args = tool["function"].get("arguments", {}) tool_names_called.append(fn_name) logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}") tool_result_content = "" - action_cfg = db_get_tool_action(fn_name) + # --- 4.1. Модуль задач --- if fn_name == "db_get_tasks": raw_tasks = db_get_tasks(user_id) reply_text = "Вот интерактивный список ваших текущих задач:" @@ -153,47 +208,88 @@ def process_chat_message( "tasks": raw_tasks } + # --- 4.2. Прямой просмотр системного промпта из SQLite --- + elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]: + active_prompt = db_get_active_system_prompt() + reply_text = f"Актуальный системный промпт:\n\n{active_prompt}" + + # Переводим сессию в PROMPT_FOLLOWUP для отслеживания Topic Drift + db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0}) + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), None + + # --- 4.3. Реляционное изменение промпта (ADD / UPDATE / DELETE) --- elif fn_name == "db_prompt_node_edit": action = fn_args.get("action", "ADD").upper() sec_id = int(fn_args.get("section_id", 2)) itm_id = int(fn_args.get("item_id", 1)) content = fn_args.get("content", "").strip() + baseline_prompt = db_get_active_system_prompt() with get_db_connection() as temp_conn: temp_cursor = temp_conn.cursor() - temp_cursor.execute("SELECT section_id, item_id, content FROM system_prompt_nodes WHERE prompt_name = 'main_agent' AND is_active = 1 ORDER BY section_id, item_id") + temp_cursor.execute(""" + SELECT section_id, item_id, content + FROM system_prompt_nodes + WHERE prompt_name = 'main_agent' AND is_active = 1 + ORDER BY section_id, item_id + """) existing_nodes = temp_cursor.fetchall() nodes_dict = {(sec, itm): txt for sec, itm, txt in existing_nodes} - - if action == "DELETE": - nodes_dict.pop((sec_id, itm_id), None) - else: - nodes_dict[(sec_id, itm_id)] = content - virtual_lines = [] + if action == "DELETE": + nodes_dict_for_draft = {k: v for k, v in nodes_dict.items() if k != (sec_id, itm_id)} + else: + nodes_dict_for_draft = dict(nodes_dict) + nodes_dict_for_draft[(sec_id, itm_id)] = content + + # Формирование чистого текста для редактора + draft_lines = [] curr_sec = None - for (s_id, i_id), txt in sorted(nodes_dict.items()): + for (s_id, i_id), txt in sorted(nodes_dict_for_draft.items()): if i_id == 0: if curr_sec is not None: - virtual_lines.append("") - virtual_lines.append(f"{s_id}. {txt}") + draft_lines.append("") + draft_lines.append(f"{s_id}. {txt}") + curr_sec = s_id + else: + draft_lines.append(f" {s_id}.{i_id}. {txt}") + merged_prompt = "\n".join(draft_lines) + + # Формирование HTML Diff с подсветкой + diff_lines = [] + curr_sec = None + display_nodes = dict(nodes_dict) + if action != "DELETE": + display_nodes[(sec_id, itm_id)] = content + + for (s_id, i_id), txt in sorted(display_nodes.items()): + if i_id == 0: + if curr_sec is not None: + diff_lines.append("") + diff_lines.append(f"{s_id}. {txt}") curr_sec = s_id else: - line_str = f" {s_id}.{i_id}. {txt}" if s_id == sec_id and i_id == itm_id: - line_str = f'{s_id}.{i_id}. {txt}' - virtual_lines.append(line_str) + if action == "DELETE": + line_str = f' {s_id}.{i_id}. {txt} [УДАЛЕНИЕ]' + else: + line_str = f' {s_id}.{i_id}. {txt}' + else: + line_str = f" {s_id}.{i_id}. {txt}" + diff_lines.append(line_str) - merged_prompt = "\n".join([re.sub(r'<[^>]+>', '', l) for l in virtual_lines]) - diff_html = "\n".join(virtual_lines) + diff_html = "\n".join(diff_lines) + # Фиксация предпросмотра в session_states db_set_session_state(session_id, "PROMPT_PREVIEW", { "draft_text": merged_prompt, "action": action, "section_id": sec_id, "item_id": itm_id, - "content": content + "content": content, + "idle_turns": 0 }) with get_db_connection() as conn_fix: @@ -214,6 +310,7 @@ def process_chat_message( return preview_reply, db_get_chat_history(session_id), { "type": "PROMPT_PREVIEW", "raw_draft": merged_prompt, + "baseline_prompt": baseline_prompt, "buttons": [ {"label": "Подтвердить", "value": "подтверждаю", "style": "primary"}, {"label": "Отменить", "value": "отмена", "style": "danger"}, @@ -221,9 +318,7 @@ def process_chat_message( ] } - elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]: - tool_result_content = json.dumps({"system_prompt": db_get_active_system_prompt()}, ensure_ascii=False) - + # --- 4.4. Сервисные инструменты --- elif fn_name == "db_get_snapshots": snapshots_res = db_get_snapshots(session_id=session_id, date_str=fn_args.get("date_str"), original_user_message=user_message) tool_result_content = json.dumps(snapshots_res, ensure_ascii=False) @@ -237,9 +332,6 @@ def process_chat_message( elif fn_name == "db_get_anomalies": tool_result_content = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False) - elif fn_name == "db_get_session_states": - tool_result_content = json.dumps(db_get_session_states(), ensure_ascii=False) - elif fn_name == "db_get_rules": tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False) @@ -264,6 +356,7 @@ def process_chat_message( messages.append({"role": "tool", "content": tool_result_content}) + # Интерпретация результатов вызова инструментов sec_msg = call_ollama_chat(messages, is_vision=False) raw_content = sec_msg.get("content", "").strip().replace("**", "").replace("*", "") final_content = clean_raw_tool_tags(clean_output(raw_content)) @@ -273,13 +366,47 @@ def process_chat_message( db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_output_ephemeral) return final_content, db_get_chat_history(session_id), None + + # ===================================================================== + # БЛОК 5: ОБЫЧНЫЙ ТЕКСТОВЫЙ ОТВЕТ И СЕМАНТИЧЕСКИЙ TOPIC DRIFT GUARD + # ===================================================================== raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "") final_reply = clean_raw_tool_tags(clean_output(raw_str)) or "Запрос обработан." + action_payload = None - db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0) - return final_reply, db_get_chat_history(session_id), None + # Обработка отвлечений оператора при активной сессии настройки/просмотра + if session_state and session_state.get("state_type") in ["PROMPT_FOLLOWUP", "PROMPT_PREVIEW"]: + idle_turns += 1 + logger.info(f"Topic Drift: активна сессия {session_state.get('state_type')}, шагов отвлечения: {idle_turns}/3") + # Порог отвлечений превышен (> 2 шагов после напоминания) -> Полная зачистка + if idle_turns > 3: + db_clear_session_state(session_id) + deleted_count = db_purge_ephemeral_messages(session_id) + logger.info(f"Topic Drift Guard TTL: сессия закрыта по таймауту, очищено {deleted_count} сообщений.") + action_payload = None + session_state = None + elif idle_turns == 3: + # На 3-м сообщении стороннего диалога вежливо спрашиваем оператора + tool_action = db_get_tool_action("db_confirm_prompt_preview") + guard_question = tool_action.get("follow_up_question", "Желаете продолжить работу с системным промптом?") if tool_action else "Желаете продолжить работу с системным промптом?" + buttons = tool_action.get("buttons", []) if tool_action else [] + + final_reply += f"\n\n💡 *Напоминание:* {guard_question}" + action_payload = { + "type": "PROMPT_FOLLOWUP", + "buttons": buttons + } + db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": idle_turns}) + else: + # Шаги 1 и 2: продолжаем обычный диалог, инкрементируя счетчик + db_set_session_state(session_id, session_state.get("state_type"), {"idle_turns": idle_turns}) + + db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=(1 if session_state else 0)) + return final_reply, db_get_chat_history(session_id), action_payload + + # Обработка исключений except Exception as ex: - logger.exception(f"Непредвиденная ошибка: {ex}") + logger.exception(f"Непредвиденная ошибка агента: {ex}") error_reply = f"Внутренняя ошибка сервера: {ex}" return error_reply, db_get_chat_history(session_id), None \ No newline at end of file diff --git a/modules/web_api/llm/core/fast_path.py b/modules/web_api/llm/core/fast_path.py index 2dcb9fa..69b6d7f 100644 --- a/modules/web_api/llm/core/fast_path.py +++ b/modules/web_api/llm/core/fast_path.py @@ -3,17 +3,18 @@ FILE: modules/web_api/llm/core/fast_path.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: web_api / llm / core -ROLE: Детерминированный перехват подтверждения, отмены и мгновенной очистки - эфемерного контекста без повторного вывода кнопок-вилок. +ROLE: Двухфазный жизненный цикл сессии (PROMPT_PREVIEW -> PROMPT_FOLLOWUP), + атомарная фиксация узлов и мягкая зачистка эфемерного контекста. =============================================================================== """ -# ANCHOR[FAST_PATH_IMPORTS] import logging from typing import Dict, Any, Tuple, Optional from llm.db_tools import ( + db_apply_prompt_node_action, db_add_system_prompt, + db_set_session_state, db_clear_session_state, db_purge_ephemeral_messages, db_save_chat_message, @@ -23,47 +24,83 @@ from llm.db_tools import ( logger = logging.getLogger("FAST_PATH") -# ANCHOR[FAST_PATH_ROUTER] def handle_fast_path_intercept( session_id: str, user_message: str, full_user_content: str, session_state: Optional[Dict[str, Any]] ) -> Optional[Tuple[str, list, Optional[Dict[str, Any]]]]: - """ - 1. Подтвердить -> запись в БД + db_purge_ephemeral_messages + инфо-текст. - 2. Отменить -> сброс стейта + db_purge_ephemeral_messages + инфо-текст. - """ - if not session_state or session_state.get("state_type") != "PROMPT_PREVIEW": + """Обрабатывает команды подтверждения, отмены и завершения сессии.""" + if not session_state: return None + state_type = session_state.get("state_type") user_msg_clean = user_message.lower().strip(" .!?:;") - draft_text = "" - if session_state.get("data_json") and isinstance(session_state["data_json"], dict): - draft_text = session_state["data_json"].get("draft_text", "") - else: - draft_text = session_state.get("pending_data", "") + # ========================================================================= + # ФАЗА 1: ОБРАБОТКА В СОСТОЯНИИ PROMPT_PREVIEW + # ========================================================================= + if state_type == "PROMPT_PREVIEW": + state_data = session_state.get("data_json") or {} + if not isinstance(state_data, dict): + state_data = {} - # 1. ПОДТВЕРДИТЬ ИЗМЕНЕНИЯ - if user_msg_clean in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]: - db_add_system_prompt("main_agent", draft_text) - db_clear_session_state(session_id) - deleted_count = db_purge_ephemeral_messages(session_id) - logger.info(f"Системный промпт сохранен в БД. Удалено эфемерных сообщений: {deleted_count}") + draft_text = state_data.get("draft_text", "") + action = state_data.get("action") + sec_id = state_data.get("section_id") + itm_id = state_data.get("item_id") + content = state_data.get("content", "") - reply_text = "Системный промпт успешно сохранен и применен в базе данных." - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0) - return reply_text, db_get_chat_history(session_id), None + # 1. ПОДТВЕРДИТЬ ИЗМЕНЕНИЯ -> ПЕРЕХОД В PROMPT_FOLLOWUP + if user_msg_clean in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]: + if action == "MANUAL_EDIT" or not action or sec_id is None: + if draft_text: + db_add_system_prompt("main_agent", draft_text) + else: + db_apply_prompt_node_action(action=action, section_id=sec_id, item_id=itm_id, content=content) - # 2. ОТМЕНИТЬ ИЗМЕНЕНИЯ - elif user_msg_clean in ["отмена", "отменить", "отклонить", "назад", "стоп"]: - db_clear_session_state(session_id) - deleted_count = db_purge_ephemeral_messages(session_id) - logger.info(f"Изменения промпта отменены. Удалено эфемерных сообщений: {deleted_count}") + logger.info("Системный промпт применен в БД. Переход в PROMPT_FOLLOWUP.") + + # Переводим сессию в режим ожидания продолжения/завершения + db_set_session_state(session_id, "PROMPT_FOLLOWUP", {}) - reply_text = "Изменения системного промпта отменены. Контекст диалога чист." - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0) - return reply_text, db_get_chat_history(session_id), None + reply_text = "Системный промпт успешно сохранен и применен в базе данных. Желаете продолжить работу с системным промптом или завершить?" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + + action_payload = { + "type": "PROMPT_FOLLOWUP", + "buttons": [ + {"label": "✨ Показать обновленный промпт", "value": "покажи системный промпт", "style": "secondary"}, + {"label": "🏁 Завершить работу", "value": "завершить работу", "style": "primary"} + ] + } + return reply_text, db_get_chat_history(session_id), action_payload + + # 2. ОТМЕНА В МОМЕНТ ПРЕВЬЮ -> ПОЛНАЯ ЗАЧИСТКА + elif user_msg_clean in ["отмена", "отменить", "отклонить", "назад", "стоп"]: + db_clear_session_state(session_id) + deleted_count = db_purge_ephemeral_messages(session_id) + logger.info(f"Изменения промпта отменены. Удалено эфемерных сообщений: {deleted_count}") + + reply_text = "Изменения системного промпта отменены. Контекст диалога чист." + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0) + return reply_text, db_get_chat_history(session_id), None + + # ========================================================================= + # ФАЗА 2: ОБРАБОТКА В СОСТОЯНИИ PROMPT_FOLLOWUP + # ========================================================================= + elif state_type == "PROMPT_FOLLOWUP": + # Явный сигнал завершения работы + if user_msg_clean in [ + "завершить работу", "завершить", "закончить", "готово", "всё", + "все", "нет", "нет, спасибо", "спасибо", "хватит", "выйти" + ]: + db_clear_session_state(session_id) + deleted_count = db_purge_ephemeral_messages(session_id) + logger.info(f"Сессия работы с промптом закрыта. Очищено эфемерных сообщений: {deleted_count}") + + reply_text = "Работа с системным промптом завершена. Чем могу помочь?" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0) + return reply_text, db_get_chat_history(session_id), None return None \ No newline at end of file diff --git a/modules/web_api/llm/core/tool_injector.py b/modules/web_api/llm/core/tool_injector.py index 0798e9d..3dd45c3 100644 --- a/modules/web_api/llm/core/tool_injector.py +++ b/modules/web_api/llm/core/tool_injector.py @@ -1,8 +1,12 @@ """ +=============================================================================== FILE: modules/web_api/llm/core/tool_injector.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / llm / core +ROLE: Санитарная очистка сырых артефактов и тегов из ответов LLM. +=============================================================================== """ import re -import json import logging from typing import List, Dict, Any @@ -10,6 +14,7 @@ logger = logging.getLogger("TOOL_INJECTOR") def clean_raw_tool_tags(text: str) -> str: + """Удаляет сырые теги вызова инструментов, если модель случайно вывела их в текст.""" if not text: return "" text = re.sub(r'\{"name":\s*"db_[^}]+\}\s*()?', '', text) @@ -20,10 +25,10 @@ def clean_raw_tool_tags(text: str) -> str: def clean_output(text: str) -> str: + """Удаляет слова-паразиты и склейки в начале ответа.""" if not text: return text - # Фильтрация паразитных артефактов токенизатора Qwen artifacts = [ "почемучка", "почемучка,", "почемучка!", "почемучка?", "почемучто", "почто", "почему что", "почему-то", @@ -39,15 +44,7 @@ def clean_output(text: str) -> str: def inject_tools_if_needed(user_message: str, raw_text_content: str, tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - if tool_calls: - return tool_calls - if '{"name":' in raw_text_content or '' in raw_text_content: - try: - match = re.search(r'\{"name":\s*"([^"]+)",\s*"(?:params|arguments|properties)":\s*(\{.*?\})\}', raw_text_content) - if match: - fn_name = match.group(1) - fn_args = json.loads(match.group(2)) - return [{"function": {"name": fn_name, "arguments": fn_args}}] - except Exception as parse_err: - logger.debug(f"Ошибка парсинга сырого tool call: {parse_err}") + """ + Проходной фильтр: все решения о вызове инструментов принимает исключительно LLM. + """ return tool_calls \ No newline at end of file diff --git a/modules/web_api/llm/db/db_prompts.py b/modules/web_api/llm/db/db_prompts.py index 95ff252..ea55934 100644 --- a/modules/web_api/llm/db/db_prompts.py +++ b/modules/web_api/llm/db/db_prompts.py @@ -7,6 +7,7 @@ ROLE: Реляционное управление системным промп базой знаний, реестром действий и сессионными стейтами. =============================================================================== """ +import re import json import logging from typing import List, Dict, Any, Optional @@ -239,7 +240,7 @@ def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]: return {"status": "success", "count": len(rows), "reference_items": [dict(r) for r in rows]} def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]: - """Мост обратной совместимости: парсит текст промпта и сохраняет по узлам в БД.""" + """Надежный парсер: восстанавливает разделы и пункты с авто-выравниванием отступов.""" try: init_prompt_nodes_table() with get_db_connection() as conn: @@ -249,30 +250,28 @@ def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]: current_sec = 1 current_itm = 0 - for line in prompt_text.splitlines(): - stripped = line.strip() - if not stripped: + for raw_line in prompt_text.splitlines(): + clean_line = re.sub(r'<[^>]+>', '', raw_line).strip() + if not clean_line: continue - - # Проверяем заголовок раздела (например, "1. РОЛЬ И ЗАДАЧИ...") - parts = stripped.split(".", 1) - if len(parts) == 2 and parts[0].strip().isdigit(): - sec_num = int(parts[0].strip()) - rest = parts[1].strip() - - # Проверяем, это подпункт (например, "1.1. Текст") или заголовок раздела - sub_parts = rest.split(".", 1) - if len(sub_parts) == 2 and sub_parts[0].strip().isdigit(): - current_sec = sec_num - current_itm = int(sub_parts[0].strip()) - content = sub_parts[1].strip() - else: - current_sec = sec_num - current_itm = 0 - content = rest + + # 1. Проверяем подпункт (например: "1.8. Текст", "1.8 Текст", "1. 8 Текст") + sub_match = re.match(r'^(\d+)[\.\s]+(\d+)[\.\s\:\-]+(.*)$', clean_line) + # 2. Проверяем заголовок раздела (например: "1. РОЛЬ И ЗАДАЧИ", "1 РОЛЬ И ЗАДАЧИ") + sec_match = re.match(r'^(\d+)[\.\s\:\-]+(.*)$', clean_line) + + if sub_match: + current_sec = int(sub_match.group(1)) + current_itm = int(sub_match.group(2)) + content = sub_match.group(3).strip() + elif sec_match and not any(c.islower() for c in sec_match.group(2)[:15]): + # Заголовок раздела (обычно капсом) + current_sec = int(sec_match.group(1)) + current_itm = 0 + content = sec_match.group(2).strip() else: current_itm += 1 - content = stripped + content = clean_line cursor.execute(""" INSERT OR REPLACE INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active) diff --git a/modules/web_api/llm/schemas.py b/modules/web_api/llm/schemas.py index df64a8f..386139e 100644 --- a/modules/web_api/llm/schemas.py +++ b/modules/web_api/llm/schemas.py @@ -1,4 +1,39 @@ TOOLS_SCHEMA = [ + { + "type": "function", + "function": { + "name": "db_prompt_node_edit", + "description": ( + "ЕДИНСТВЕННЫЙ инструмент для добавления, изменения или УДАЛЕНИЯ пунктов системного промпта. " + "Вызывай ВСЕГДА при фразах 'удали X.Y', 'удалить пункт X.Y', 'добавь X.Y', 'измени X.Y'. " + "При удалении: action='DELETE', section_id=X, item_id=Y, content=''. " + "Запрещено отвечать текстовым подтверждением без вызова этой функции!" + ), + "parameters": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["ADD", "UPDATE", "DELETE"], + "description": "ADD (добавить), UPDATE (изменить), DELETE (удалить)" + }, + "section_id": { + "type": "integer", + "description": "Номер раздела (например: 1, 2, 3)" + }, + "item_id": { + "type": "integer", + "description": "Номер пункта (например: 4, 8, 9)" + }, + "content": { + "type": "string", + "description": "Текст пункта (для DELETE передается пустая строка)" + } + }, + "required": ["action", "section_id", "item_id"] + } + } + }, { "type": "function", "function": { @@ -169,39 +204,5 @@ TOOLS_SCHEMA = [ } } } - }, - { - "type": "function", - "function": { - "name": "db_prompt_node_edit", - "description": ( - "Сформировать предпросмотр изменения системного промпта через реляционные узлы. " - "Вызывай при любых запросах на добавление, изменение или удаление пунктов. " - "Раздел и пункт передавай числами (например, для пункта '2.9' -> section_id=2, item_id=9)." - ), - "parameters": { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["ADD", "UPDATE", "DELETE"], - "description": "Действие: ADD (добавить пункт), UPDATE (изменить пункт), DELETE (удалить пункт)" - }, - "section_id": { - "type": "integer", - "description": "Номер раздела (например, 2)" - }, - "item_id": { - "type": "integer", - "description": "Номер пункта внутри раздела (например, 9)" - }, - "content": { - "type": "string", - "description": "Текст формулировки пункта (для ADD и UPDATE). Без префикса номера!" - } - }, - "required": ["action", "section_id", "item_id"] - } - } } ] \ No newline at end of file diff --git a/modules/web_api/routers/chat.py b/modules/web_api/routers/chat.py index 639790b..1dce55e 100644 --- a/modules/web_api/routers/chat.py +++ b/modules/web_api/routers/chat.py @@ -82,5 +82,12 @@ def update_draft_endpoint( if not state or state.get("state_type") != "PROMPT_PREVIEW": raise HTTPException(status_code=400, detail="Нет активного превью для редактирования") - db_set_session_state(req.session_id, "PROMPT_PREVIEW", {"draft_text": req.draft_text.strip()}) + # Сохраняем чистый текст с пометкой MANUAL_EDIT + db_set_session_state(req.session_id, "PROMPT_PREVIEW", { + "draft_text": req.draft_text.strip(), + "action": "MANUAL_EDIT", + "section_id": None, + "item_id": None, + "content": "" + }) return {"status": "success", "message": "Черновик успешно обновлен в сессии"} \ No newline at end of file diff --git a/modules/web_api/scripts/diagnostics/inspect_db.py b/modules/web_api/scripts/diagnostics/inspect_db.py deleted file mode 100644 index 4f7a02f..0000000 --- a/modules/web_api/scripts/diagnostics/inspect_db.py +++ /dev/null @@ -1,47 +0,0 @@ -import os -import sqlite3 - -# Автопоиск файла базы данных в проекте -db_path = '/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db' if os.path.exists('/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db') else 'scud_orion_ai.db' - -print("=" * 80) -print(f"🔍 ДИАГНОСТИКА СУБД SQLITE: {db_path}") -print("=" * 80) - -if not os.path.exists(db_path): - print(f"❌ Файл базы данных {db_path} не найден!") - exit(1) - -conn = sqlite3.connect(db_path) -cursor = conn.cursor() - -# 1. Список всех таблиц и колонок -cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") -tables = [t[0] for t in cursor.fetchall()] - -print("\n📋 СТРУКТУРА ТАБЛИЦ И КОЛИЧЕСТВО ЗАПИСЕЙ:") -print("-" * 80) -for t_name in tables: - cursor.execute(f"PRAGMA table_info({t_name})") - cols = [c[1] for c in cursor.fetchall()] - - cursor.execute(f"SELECT COUNT(*) FROM {t_name}") - count = cursor.fetchone()[0] - - print(f"• [{t_name:<20}] — {count:>6} строк | Колонки: {cols}") - -# 2. Просмотр правил Базы Знаний -if 'ai_knowledge_base' in tables: - print("\n" + "=" * 80) - print("🧠 АКТУАЛЬНЫЕ ПРАВИЛА БАЗЫ ЗНАНИЙ (ai_knowledge_base):") - print("=" * 80) - cursor.execute("SELECT id, rule_text, added_by FROM ai_knowledge_base ORDER BY id ASC") - rules = cursor.fetchall() - if not rules: - print("Таблица ai_knowledge_base пуста.") - else: - for r_id, r_text, r_author in rules: - print(f" {r_id}. [{r_author}] {r_text}\n") - -conn.close() -print("=" * 80) \ No newline at end of file diff --git a/modules/web_api/scripts/diagnostics/inspect_files.py b/modules/web_api/scripts/diagnostics/inspect_files.py deleted file mode 100644 index fa3a4f3..0000000 --- a/modules/web_api/scripts/diagnostics/inspect_files.py +++ /dev/null @@ -1,23 +0,0 @@ -import os - -print("=" * 80) -print("📂 ТЕКУЩЕЕ СОСТОЯНИЕ ФАЙЛОВ ПРОЕКТА (scud_orion_context)") -print("=" * 80) - -total_files = 0 -total_size = 0 - -for root, dirs, files in os.walk('.'): - # Исключаем служебные каталоги - dirs[:] = [d for d in dirs if d not in ['.git', '__pycache__', 'venv', '.venv', 'extracted_project']] - - for f in files: - p = os.path.join(root, f) - size = os.path.getsize(p) - total_files += 1 - total_size += size - print(f"{p:<55} ({size:>10,} bytes)".replace(',', ' ')) - -print("-" * 80) -print(f"ИТОГО: файлов: {total_files} | Общий объем: {total_size / (1024 * 1024):.2f} MB") -print("=" * 80) \ No newline at end of file diff --git a/modules/web_api/scripts/diagnostics/make_code_snapshot.py b/modules/web_api/scripts/diagnostics/make_code_snapshot.py deleted file mode 100644 index 066bb1e..0000000 --- a/modules/web_api/scripts/diagnostics/make_code_snapshot.py +++ /dev/null @@ -1,31 +0,0 @@ -import os - -OUTPUT_SNAPSHOT = "api_code_snapshot.md" - -# Расширения файлов для включения в снимок -ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini', '.js', '.html', '.css'} -EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'} -EXCLUDE_FILES = {OUTPUT_SNAPSHOT, 'scud_context_api.tar.gz', 'context_memory.db'} - -print(f"🔄 Сборка полного контекстного слепка проекта в {OUTPUT_SNAPSHOT}...") - -with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out: - out.write("# 📦 ПОЛНЫЙ ИСХОДНЫЙ КОД И КОНФИГУРАЦИЯ ПРОЕКТА scud_context_api\n\n") - - for root, dirs, files in os.walk('.'): - dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS] - - for file in sorted(files): - ext = os.path.splitext(file)[1].lower() - if ext in ALLOWED_EXTENSIONS and file not in EXCLUDE_FILES: - filepath = os.path.join(root, file) - out.write(f"## File: `{filepath}`\n") - out.write("```" + (ext.replace('.', '') if ext != '.md' else '') + "\n") - try: - with open(filepath, 'r', encoding='utf-8', errors='replace') as f: - out.write(f.read()) - except Exception as e: - out.write(f"// Ошибка чтения файла: {e}\n") - out.write("\n```\n\n") - -print(f"✓ Успешно создан слепок проекта: {OUTPUT_SNAPSHOT} ({os.path.getsize(OUTPUT_SNAPSHOT):,} bytes)") \ No newline at end of file diff --git a/modules/web_api/static/js/chat.js b/modules/web_api/static/js/chat.js deleted file mode 100644 index 94b41dc..0000000 --- a/modules/web_api/static/js/chat.js +++ /dev/null @@ -1,568 +0,0 @@ -/** -=============================================================================== -FILE: modules/web_api/static/js/chat.js -ROLE: Клиентский интерфейс диалога, Drag-and-Drop вложений, генерация динамических - кнопок подтверждений и блокировка ввода при активном действии. -=============================================================================== -*/ - -function updateInputHeight(el) { - if (!el) return; - el.style.height = "24px"; - const newHeight = Math.min(el.scrollHeight, 120); - el.style.height = newHeight + "px"; -} - -let selectedFile = null; - -function handleFileSelect(e) { - const file = e.target.files[0]; - if (!file) return; - - if (file.size > 15 * 1024 * 1024) { - alert("Файл слишком большой. Максимальный размер: 15 МБ"); - e.target.value = ""; - return; - } - - selectedFile = file; - const fileNameEl = document.getElementById("file-name-display"); - const fileSizeEl = document.getElementById("file-size-display"); - const previewContainer = document.getElementById("file-preview-container"); - - if (fileNameEl) fileNameEl.innerText = file.name; - if (fileSizeEl) fileSizeEl.innerText = `(${(file.size / 1024).toFixed(1)} KB)`; - if (previewContainer) previewContainer.classList.remove("hidden"); -} - -function clearAttachedFile() { - selectedFile = null; - const fileInput = document.getElementById("file-input"); - const previewContainer = document.getElementById("file-preview-container"); - if (fileInput) fileInput.value = ""; - if (previewContainer) previewContainer.classList.add("hidden"); -} - -function setInputLocked(isLocked) { - const input = document.getElementById("user-input"); - const sendBtn = document.getElementById("send-btn"); - if (input) { - input.disabled = isLocked; - if (isLocked) { - input.placeholder = "Выберите действие с помощью кнопок выше..."; - input.classList.add("cursor-not-allowed", "opacity-60"); - } else { - input.placeholder = "Команда, вопрос или перетащите файл сюда..."; - input.classList.remove("cursor-not-allowed", "opacity-60"); - } - } - if (sendBtn) { - sendBtn.disabled = isLocked; - if (isLocked) { - sendBtn.classList.add("opacity-40", "cursor-not-allowed"); - } else { - sendBtn.classList.remove("opacity-40", "cursor-not-allowed"); - } - } -} - -function disableAllActionButtons() { - const allBtnContainers = document.querySelectorAll(".action-buttons-container"); - allBtnContainers.forEach(container => { - container.querySelectorAll("button").forEach(btn => { - btn.disabled = true; - btn.classList.add("opacity-40", "cursor-not-allowed"); - }); - }); -} - -function handleActionButtonClick(text) { - disableAllActionButtons(); - setInputLocked(false); - const input = document.getElementById("user-input"); - if (input) { - input.value = text; - sendMessage(); - } -} - -// --- [INTERACTIVE TASK WIDGET] --- -let activeWidgetTasksMap = new Map(); - -function renderTaskItemsHtml(tasks, filterStatus) { - const filtered = tasks.filter(t => { - if (filterStatus === "ALL") return true; - return t.status === filterStatus; - }); - - if (filtered.length === 0) { - const label = filterStatus === 'BACKLOG' ? 'В планах' : filterStatus === 'IN_PROGRESS' ? 'В работе' : filterStatus === 'COMPLETED' ? 'Завершенные' : 'Все'; - return `
Нет задач со статусом «${label}»
`; - } - - return filtered.map(t => { - const isDone = t.status === "COMPLETED"; - const isProgress = t.status === "IN_PROGRESS"; - const checkedAttr = isDone ? "checked" : ""; - const textClass = isDone ? "line-through text-slate-400" : "text-slate-800 font-medium"; - - let statusPill = `В планах`; - if (isDone) { - statusPill = `Завершено`; - } else if (isProgress) { - statusPill = `В работе`; - } - - let prioPill = ""; - if (t.priority === "HIGH") { - prioPill = `HIGH`; - } - - return ` -
-
- -
-
- ${t.task_id} - ${prioPill} - ${statusPill} -
- ${escapeHtml(t.title)} -
-
- -
- `; - }).join(""); -} - -function renderInteractiveTaskCard(tasks) { - if (!tasks || !Array.isArray(tasks)) return ""; - const widgetId = "task_widget_" + Date.now(); - activeWidgetTasksMap.set(widgetId, { tasks: tasks, filter: "ALL" }); - - const itemsHtml = renderTaskItemsHtml(tasks, "ALL"); - - return ` -
-
- - - - -
- -
- ${itemsHtml} -
- -
- - -
-
- `; -} - -function filterTaskWidget(widgetId, status, btnEl) { - const state = activeWidgetTasksMap.get(widgetId); - if (!state) return; - state.filter = status; - - const widgetEl = document.getElementById(widgetId); - if (!widgetEl) return; - - widgetEl.querySelectorAll(".widget-tab-btn").forEach(b => { - b.className = "widget-tab-btn px-2.5 py-1 rounded-lg text-slate-500 hover:text-slate-700"; - }); - btnEl.className = "widget-tab-btn px-2.5 py-1 rounded-lg bg-indigo-50 text-indigo-600 font-bold border border-indigo-200"; - - const container = widgetEl.querySelector(".widget-items-container"); - if (container) { - container.innerHTML = renderTaskItemsHtml(state.tasks, status); - } -} - -async function toggleTaskStatusInline(taskId, isChecked) { - const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); - const newStatus = isChecked ? "COMPLETED" : "BACKLOG"; - - try { - const res = await fetch(`/api/v1/tasks/${taskId}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - "Authorization": "Bearer " + token - }, - body: JSON.stringify({ status: newStatus }) - }); - - if (res.ok) { - for (let [wId, state] of activeWidgetTasksMap.entries()) { - const targetTask = state.tasks.find(t => t.task_id === taskId); - if (targetTask) { - targetTask.status = newStatus; - const widgetEl = document.getElementById(wId); - const container = widgetEl?.querySelector(".widget-items-container"); - if (container) { - container.innerHTML = renderTaskItemsHtml(state.tasks, state.filter); - } - } - } - if (typeof loadTasks === "function") loadTasks(); - } - } catch (err) { - console.error("[Task Patch Error]", err); - } -} - -async function deleteTaskInline(taskId) { - if (!confirm(`Удалить задачу ${taskId}?`)) return; - const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); - - try { - const res = await fetch(`/api/v1/tasks/${taskId}`, { - method: "DELETE", - headers: { "Authorization": "Bearer " + token } - }); - - if (res.ok) { - for (let [wId, state] of activeWidgetTasksMap.entries()) { - state.tasks = state.tasks.filter(t => t.task_id !== taskId); - const widgetEl = document.getElementById(wId); - const container = widgetEl?.querySelector(".widget-items-container"); - if (container) { - container.innerHTML = renderTaskItemsHtml(state.tasks, state.filter); - } - } - if (typeof loadTasks === "function") loadTasks(); - } - } catch (err) { - console.error("[Task Delete Error]", err); - } -} - -async function addTaskInline(widgetId, inputEl) { - if (!inputEl) return; - const title = inputEl.value.trim(); - if (!title) return; - - const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); - - try { - const res = await fetch("/api/v1/tasks", { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": "Bearer " + token - }, - body: JSON.stringify({ title: title, priority: "MEDIUM", module: "general" }) - }); - - if (res.ok) { - inputEl.value = ""; - const tasksRes = await fetch("/api/v1/tasks", { - headers: { "Authorization": "Bearer " + token } - }); - const updatedTasks = await tasksRes.json(); - - const state = activeWidgetTasksMap.get(widgetId); - if (state) { - state.tasks = updatedTasks; - const widgetEl = document.getElementById(widgetId); - const container = widgetEl?.querySelector(".widget-items-container"); - if (container) { - container.innerHTML = renderTaskItemsHtml(updatedTasks, state.filter); - } - } - if (typeof loadTasks === "function") loadTasks(); - } - } catch (err) { - console.error("[Task Add Error]", err); - } -} - -// --- [CHAT SEND PIPELINE] --- -async function sendMessage(e) { - if (e && e.preventDefault) e.preventDefault(); - - const input = document.getElementById("user-input"); - const chatWindow = document.getElementById("chat-window"); - const sendBtn = document.getElementById("send-btn"); - - if (!input || !chatWindow) return; - const text = input.value.trim(); - - if (!text && !selectedFile) return; - - disableAllActionButtons(); - - let userDisplayHtml = escapeHtml(text); - if (selectedFile) { - userDisplayHtml = `
- ${escapeHtml(selectedFile.name)} -
` + userDisplayHtml; - } - - const userMsgHtml = ` -
-
- ${userDisplayHtml} -
-
- `; - chatWindow.insertAdjacentHTML("beforeend", userMsgHtml); - - input.value = ""; - updateInputHeight(input); - chatWindow.scrollTop = chatWindow.scrollHeight; - - if (sendBtn) { - sendBtn.disabled = true; - sendBtn.classList.add("opacity-50"); - } - - const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); - const isGuest = typeof IS_GUEST !== 'undefined' ? IS_GUEST : (localStorage.getItem("scud_is_guest") === "true"); - - const endpoint = isGuest ? "/api/v1/chat/guest" : "/api/v1/chat"; - - const formData = new FormData(); - formData.append("session_id", "web_session_main"); - formData.append("message", text || "Проанализируй прикрепленный файл"); - - if (selectedFile instanceof File) { - formData.append("file", selectedFile, selectedFile.name); - } - - const headers = {}; - if (!isGuest && token) { - headers["Authorization"] = "Bearer " + token; - } - - try { - const res = await fetch(endpoint, { - method: "POST", - headers: headers, - body: formData - }); - - if (res.status === 401 && !isGuest) { - if (typeof logout === 'function') logout(); - return; - } - - const data = await res.json(); - const assistantTitle = isGuest ? "Локальная нейросеть (Гость)" : "ИИ-Ассистент SCUD Orion AI"; - const replyText = data.reply || "Пустой ответ от нейросети"; - - let actionButtonsHtml = ""; - const actionData = data.action_type || data.action; - - if (actionData && actionData.buttons && actionData.buttons.length > 0) { - const buttonsMarkup = actionData.buttons.map(btn => { - let btnClasses = "bg-slate-100 hover:bg-slate-200 active:bg-slate-300 text-slate-700 border border-slate-300"; - let iconMarkup = ''; - - const labelLower = (btn.label || "").toLowerCase(); - const isPrimary = btn.style === "primary" || labelLower.includes("подтверд") || labelLower.startsWith("да"); - const isDanger = btn.style === "danger" || labelLower.includes("отмен") || labelLower.startsWith("нет") || labelLower.includes("законч") || labelLower.includes("заверш"); - - if (isPrimary) { - btnClasses = "bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white shadow-sm"; - iconMarkup = ''; - } else if (isDanger) { - btnClasses = "bg-rose-50 hover:bg-rose-100 active:bg-rose-200 text-rose-700 border border-rose-300"; - iconMarkup = ''; - } - - return ` - - `; - }).join(""); - - actionButtonsHtml = ` -
- ${buttonsMarkup} -
- `; - - // Если выведены кнопки подтверждения превью — блокируем ввод с клавиатуры - if (actionData.type === "PROMPT_PREVIEW") { - setInputLocked(true); - } else { - setInputLocked(false); - } - } else { - setInputLocked(false); - } - - let interactiveWidgetHtml = ""; - if (actionData && actionData.type === "TASK_INTERACTIVE_CARD" && Array.isArray(actionData.tasks)) { - interactiveWidgetHtml = renderInteractiveTaskCard(actionData.tasks); - } - - const botMsgHtml = ` -
-

- ${assistantTitle} -

-

${escapeHtml(replyText)}

- ${interactiveWidgetHtml} - ${actionButtonsHtml} -
- `; - chatWindow.insertAdjacentHTML("beforeend", botMsgHtml); - chatWindow.scrollTop = chatWindow.scrollHeight; - - clearAttachedFile(); - - if (!isGuest && typeof loadTasks === 'function') { - loadTasks(); - } - - } catch (err) { - console.error("[Chat Error]", err); - const errorHtml = ` -
- Ошибка связи с сервером. -
- `; - chatWindow.insertAdjacentHTML("beforeend", errorHtml); - chatWindow.scrollTop = chatWindow.scrollHeight; - setInputLocked(false); - } finally { - if (sendBtn && !document.getElementById("user-input")?.disabled) { - sendBtn.disabled = false; - sendBtn.classList.remove("opacity-50"); - } - } -} - -function escapeHtml(text) { - if (!text) return ""; - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -document.addEventListener("DOMContentLoaded", () => { - const input = document.getElementById("user-input"); - const dropZone = document.getElementById("chat-window")?.parentElement; - const dropOverlay = document.getElementById("drop-overlay"); - - if (input) { - let historyIndex = -1; - let localHistory = JSON.parse(localStorage.getItem("scud_chat_input_history") || "[]"); - - input.addEventListener("keydown", (e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - if (input.disabled) return; - const text = input.value.trim(); - if (text) { - if (localHistory.length === 0 || localHistory[0] !== text) { - localHistory.unshift(text); - if (localHistory.length > 50) localHistory.pop(); - localStorage.setItem("scud_chat_input_history", JSON.stringify(localHistory)); - } - historyIndex = -1; - } - sendMessage(e); - updateInputHeight(input); - return; - } - - if (e.key === "ArrowUp") { - const textBeforeCursor = input.value.substring(0, input.selectionStart); - const isFirstLine = !textBeforeCursor.includes("\n"); - - if (isFirstLine && input.selectionStart === 0 && localHistory.length > 0) { - if (historyIndex < localHistory.length - 1) { - e.preventDefault(); - if (historyIndex === -1) { - input.dataset.draft = input.value; - } - historyIndex++; - input.value = localHistory[historyIndex]; - updateInputHeight(input); - input.setSelectionRange(input.value.length, input.value.length); - } - } - } - - if (e.key === "ArrowDown") { - const textAfterCursor = input.value.substring(input.selectionEnd); - const isLastLine = !textAfterCursor.includes("\n"); - - if (isLastLine && input.selectionEnd === input.value.length && historyIndex >= 0) { - e.preventDefault(); - if (historyIndex > 0) { - historyIndex--; - input.value = localHistory[historyIndex]; - } else { - historyIndex = -1; - input.value = input.dataset.draft || ""; - } - updateInputHeight(input); - input.setSelectionRange(input.value.length, input.value.length); - } - } - }); - } - - if (dropZone && dropOverlay) { - ["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => { - dropZone.addEventListener(eventName, (e) => { - e.preventDefault(); - e.stopPropagation(); - }, false); - }); - - ["dragenter", "dragover"].forEach(eventName => { - dropZone.addEventListener(eventName, () => { - dropOverlay.classList.remove("hidden"); - dropOverlay.classList.add("flex"); - }, false); - }); - - ["dragleave", "drop"].forEach(eventName => { - dropZone.addEventListener(eventName, (e) => { - if (eventName === "drop" || e.target === dropZone || !dropZone.contains(e.relatedTarget)) { - dropOverlay.classList.add("hidden"); - dropOverlay.classList.remove("flex"); - } - }, false); - }); - - dropZone.addEventListener("drop", (e) => { - const dt = e.dataTransfer; - const files = dt.files; - - if (files && files.length > 0) { - const file = files[0]; - handleFileSelect({ target: { files: [file] } }); - - const fileInput = document.getElementById("file-input"); - if (fileInput) { - const dataTransfer = new DataTransfer(); - dataTransfer.items.add(file); - fileInput.files = dataTransfer.files; - } - } - }, false); - } -}); \ No newline at end of file diff --git a/modules/web_api/static/js/chat/core.js b/modules/web_api/static/js/chat/core.js index 2545048..de806f9 100644 --- a/modules/web_api/static/js/chat/core.js +++ b/modules/web_api/static/js/chat/core.js @@ -4,7 +4,7 @@ FILE: modules/web_api/static/js/chat/core.js PROJECT: SCUD Orion AI (Unified Architecture) MODULE: web_api / static / js / chat ROLE: Клиентский интерфейс диалога, Drag-and-Drop вложений, блокировка ввода - при активных кнопках, вызов онлайн-редактора и отправка правок. + при активных кнопках, динамический вызов инлайн-редактора и двусторонний Diff. =============================================================================== */ @@ -79,11 +79,6 @@ function disableAllActionButtons() { } function handleActionButtonClick(text) { - if (text === "action:open_editor") { - openInlinePromptEditor(); - return; - } - disableAllActionButtons(); setInputLocked(false); const input = document.getElementById("user-input"); @@ -93,22 +88,123 @@ function handleActionButtonClick(text) { } } -function openInlinePromptEditor() { - const editorContainer = document.getElementById("inline-prompt-editor-container"); - if (editorContainer) { +function escapeHtml(text) { + if (!text) return ""; + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +// --- [INLINE PROMPT EDITOR & FULL DIFF BUILDER] --- +function openInlinePromptEditor(btnEl) { + const cardContainer = btnEl.closest(".chat-message-card"); + if (!cardContainer) return; + + const editorContainer = cardContainer.querySelector(".inline-prompt-editor-container"); + const diffView = cardContainer.querySelector(".prompt-preview-diff-view"); + const textarea = cardContainer.querySelector(".inline-prompt-textarea"); + + if (editorContainer && textarea) { editorContainer.classList.remove("hidden"); - const textarea = document.getElementById("inline-prompt-textarea"); - if (textarea) textarea.focus(); + + if (diffView) { + const targetHeight = Math.max(diffView.offsetHeight, 320); + textarea.style.height = `${targetHeight}px`; + } + + textarea.focus(); } } -function closeInlinePromptEditor() { - const editorContainer = document.getElementById("inline-prompt-editor-container"); +function closeInlinePromptEditor(btnEl) { + const cardContainer = btnEl.closest(".chat-message-card"); + if (!cardContainer) return; + const editorContainer = cardContainer.querySelector(".inline-prompt-editor-container"); if (editorContainer) editorContainer.classList.add("hidden"); } -async function saveManualPromptDraft() { - const textarea = document.getElementById("inline-prompt-textarea"); +function parsePromptIntoMap(text) { + const map = new Map(); + if (!text) return map; + const lines = text.split("\n"); + for (let line of lines) { + let trimmed = line.trim(); + if (!trimmed) continue; + const match = trimmed.match(/^(\d+)[\.\s]+(\d+)[\.\s\:\-]*(.*)$/); + if (match) { + const key = `${match[1]}.${match[2]}`; + map.set(key, { sec: parseInt(match[1]), itm: parseInt(match[2]), content: match[3].trim(), full: trimmed }); + } + } + return map; +} + +function buildHighlightedPromptHtml(newRawText, originalBaselineText) { + const origMap = parsePromptIntoMap(originalBaselineText); + const newMap = parsePromptIntoMap(newRawText); + + const formattedLines = []; + const rawLines = newRawText.split("\n"); + const handledNewKeys = new Set(); + + let currentSection = null; + + for (let line of rawLines) { + let trimmed = line.trim(); + if (!trimmed) { + formattedLines.push(""); + continue; + } + + const secMatch = trimmed.match(/^(\d+)[\.\s\:\-]+([^\d].*)$/); + const subMatch = trimmed.match(/^(\d+)[\.\s]+(\d+)[\.\s\:\-]*(.*)$/); + + if (subMatch) { + const key = `${subMatch[1]}.${subMatch[2]}`; + handledNewKeys.add(key); + currentSection = parseInt(subMatch[1]); + const cleanContent = subMatch[3].trim(); + const fullItemStr = `${subMatch[1]}.${subMatch[2]}. ${cleanContent}`; + + if (!origMap.has(key) || origMap.get(key).content !== cleanContent) { + // Добавленный или изменённый пункт + formattedLines.push(` ${escapeHtml(fullItemStr)}`); + } else { + formattedLines.push(` ${escapeHtml(fullItemStr)}`); + } + } else if (secMatch && !anyLower(secMatch[2].slice(0, 15))) { + // Заголовок раздела + currentSection = parseInt(secMatch[1]); + formattedLines.push(escapeHtml(trimmed)); + } else { + formattedLines.push(escapeHtml(trimmed)); + } + } + + // Добавляем удаленные пункты (были в оригинале, но отсутствуют в новом тексте) + for (let [origKey, origObj] of origMap.entries()) { + if (!handledNewKeys.has(origKey)) { + const strikeMarkup = ` ${origKey}. ${escapeHtml(origObj.content)} [УДАЛЕНИЕ]`; + formattedLines.push(strikeMarkup); + } + } + + return formattedLines.join("\n"); +} + +function anyLower(str) { + return /[а-яa-z]/.test(str); +} + +async function saveManualPromptDraft(btnEl) { + const cardContainer = btnEl.closest(".chat-message-card"); + if (!cardContainer) return; + + const textarea = cardContainer.querySelector(".inline-prompt-textarea"); + const previewTextEl = cardContainer.querySelector(".prompt-preview-diff-view"); if (!textarea) return; const newText = textarea.value.trim(); @@ -130,28 +226,18 @@ async function saveManualPromptDraft() { }); if (res.ok) { - closeInlinePromptEditor(); - const previewTextEl = document.getElementById("prompt-preview-diff-view"); + closeInlinePromptEditor(btnEl); if (previewTextEl) { - previewTextEl.innerText = newText; + const baseline = cardContainer.dataset.originalBaseline || ""; + const highlightedHtml = buildHighlightedPromptHtml(newText, baseline); + previewTextEl.innerHTML = highlightedHtml; } - alert("✓ Правки сохранены в черновике! Нажмите «Подтвердить» для применения в БД."); } } catch (err) { alert("Ошибка сохранения черновика: " + err); } } -function escapeHtml(text) { - if (!text) return ""; - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - // --- [CHAT SEND PIPELINE] --- async function sendMessage(e) { if (e && e.preventDefault) e.preventDefault(); @@ -250,8 +336,10 @@ async function sendMessage(e) { iconMarkup = ''; } + const clickHandler = isEdit ? "openInlinePromptEditor(this)" : `handleActionButtonClick('${escapeHtml(btn.value)}')`; + return ` - - + +
+ +
`; @@ -291,11 +383,11 @@ async function sendMessage(e) { } const botMsgHtml = ` -
+

${assistantTitle}

-
${replyText}
+
${replyText}
${previewEditorHtml} ${interactiveWidgetHtml} ${actionButtonsHtml} diff --git a/project_code_snapshot.md b/project_code_snapshot.md index 9bf59ee..ffa9679 100644 --- a/project_code_snapshot.md +++ b/project_code_snapshot.md @@ -1,4 +1,4 @@ -# 📦 ПОЛНЫЙ ИСХОДНЫЙ КОД И КОНФИГУРАЦИЯ ПРОЕКТА scud_orion_ai_v2 +# 📦 ОПТИМИЗИРОВАННЫЙ СЛЕПОК ИСХОДНОГО КОДА scud_orion_ai ## File: `./config.py` ```py @@ -774,11 +774,14 @@ def print_stats(): print("=" * 60) with get_connection() as conn: cursor = conn.cursor() - tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base'] + tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'chat_messages', 'session_states'] for t in tables: - cursor.execute(f"SELECT COUNT(*) FROM {t}") - cnt = cursor.fetchone()[0] - print(f" • Таблица [{t:<18}]: {cnt:>6} записей") + try: + cursor.execute(f"SELECT COUNT(*) FROM {t}") + cnt = cursor.fetchone()[0] + print(f" • Таблица [{t:<18}]: {cnt:>6} записей") + except Exception: + pass print("=" * 60 + "\n") @@ -959,28 +962,35 @@ def dump_all_to_excel(out_filename="db_dump_full.xlsx"): out_path = os.path.join(OUTPUT_DIR, out_filename) print(f"\n[🔄] Создание полного дампа БД в файл: {out_path} ...") with get_connection() as conn, pd.ExcelWriter(out_path, engine='openpyxl') as writer: - for table in ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base']: - df = pd.read_sql_query(f"SELECT * FROM {table}", conn) - df.to_excel(writer, sheet_name=table[:31], index=False) + for table in ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'chat_messages', 'session_states', 'system_prompt_nodes', 'tasks']: + try: + df = pd.read_sql_query(f"SELECT * FROM {table}", conn) + df.to_excel(writer, sheet_name=table[:31], index=False) + except Exception: + pass print(f"[✓] Дамп успешно сохранен: {out_path}\n") def print_system_prompts(): """Выводит все системные промпты из базы данных.""" print("\n" + "=" * 80) - print("📝 СИСТЕМНЫЕ ПРОМПТЫ (system_prompts):") + print("📝 СИСТЕМНЫЕ ПРОМПТЫ (system_prompt_nodes):") print("=" * 80) with get_connection() as conn: cursor = conn.cursor() - cursor.execute("SELECT id, name, is_active, updated_at, prompt_text FROM system_prompts ORDER BY id DESC") - rows = cursor.fetchall() + try: + cursor.execute("SELECT section_id, item_id, content, is_active, updated_at FROM system_prompt_nodes ORDER BY section_id ASC, item_id ASC") + rows = cursor.fetchall() + except Exception: + rows = [] + if not rows: - print("Таблица system_prompts пуста.") + print("Таблица system_prompt_nodes пуста.") else: for r in rows: - print(f"ID: {r[0]} | Name: {r[1]} | Active: {r[2]} | Updated: {r[3]}") + print(f"Раздел {r[0]}.{r[1]} | Active: {r[3]} | Updated: {r[4]}") + print(f" {r[2]}") print("-" * 80) - print(f"{r[4]}\n") print("=" * 80 + "\n") @@ -1003,6 +1013,50 @@ def print_session_states(): print("=" * 80 + "\n") +def print_chat_messages(session_id=None, limit=50): + """Выводит таблицу истории сообщений чата (контекст).""" + print("\n" + "=" * 105) + print(f"💬 ИСТОРИЯ СООБЩЕНИЙ ЧАТА (chat_messages) {f'для сессии: {session_id}' if session_id else 'все сессии'}:") + print("=" * 105) + + with get_connection() as conn: + cursor = conn.cursor() + if session_id: + cursor.execute(""" + SELECT id, session_id, role, content, is_ephemeral, created_at + FROM chat_messages + WHERE session_id = ? + ORDER BY id ASC + LIMIT ? + """, (session_id, limit)) + else: + cursor.execute(""" + SELECT id, session_id, role, content, is_ephemeral, created_at + FROM chat_messages + ORDER BY id DESC + LIMIT ? + """, (limit,)) + + rows = cursor.fetchall() + if not rows: + print("Таблица chat_messages пуста.") + else: + # Если выводили все без конкретной сессии в порядке DESC, развернем для читаемости + if not session_id: + rows = list(reversed(rows)) + + for r in rows: + msg_id, sess, role, content, ephemeral, created = r + eph_marker = " [ЭФЕМЕРНОЕ]" if ephemeral else "" + print(f"[{msg_id}] {created} | Сессия: {sess} | Роль: {role.upper()}{eph_marker}") + print("-" * 105) + # Выводим контент, ограничивая длинные сообщения при необходимости + content_preview = content if content else "" + print(f"{content_preview}") + print("=" * 105) + print("\n") + + HELP_TEXT = """ CLI-утилита инспекции и управления SQLite базой данных СКУД (scud_orion_ai.db) @@ -1013,9 +1067,10 @@ CLI-утилита инспекции и управления SQLite базой absences [ДД.ММ.ГГГГ] -- Посмотреть список официально отсутствующих из 1С:ЗУП anomalies -- Посмотреть историю найденных аномалий СКУД ⟷ 1С rules -- Посмотреть правила Базы Знаний ИИ из SQLite - prompts -- Посмотреть системные промпты (system_prompts) + prompts -- Посмотреть узлы системного промпта (system_prompt_nodes) tools -- Посмотреть реестр действий инструментов и кнопок (tool_action_registry) sessions -- Посмотреть активные сессии и превью (session_states) + context [session_id] [--limit N] -- Посмотреть таблицу контекста сообщений чата (chat_messages) dump [output.xlsx] -- Полный дамп всех таблиц БД в многостраничный Excel snapshot del [ID] или [--day ДД.ММ.ГГГГ] -- Удаление снапшота по ID или всех за выбранный день @@ -1023,13 +1078,12 @@ CLI-утилита инспекции и управления SQLite базой python scripts/db_cli.py stats python scripts/db_cli.py snapshots 06.08.2026 python scripts/db_cli.py scud 06.08.2026 --export-xlsx срез_четверг - python scripts/db_cli.py scud --snapshot Y20260805-007 python scripts/db_cli.py absences 07.08.2026 python scripts/db_cli.py prompts python scripts/db_cli.py tools python scripts/db_cli.py sessions + python scripts/db_cli.py context web_session_main --limit 20 python scripts/db_cli.py snapshot del Y20260805-007 - python scripts/db_cli.py snapshot del --day 04.08.2026 python scripts/db_cli.py dump my_dump.xlsx """ @@ -1044,12 +1098,13 @@ def main(): formatter_class=argparse.RawDescriptionHelpFormatter, add_help=False ) - parser.add_argument('command', nargs='?', default=None, choices=['stats', 'snapshots', 'scud', 'absences', 'anomalies', 'rules', 'prompts', 'sessions', 'dump', 'snapshot', 'tools'], help="Основная команда") - parser.add_argument('action', nargs='?', default=None, help="Дополнительное действие (например, 'del')") + parser.add_argument('command', nargs='?', default=None, choices=['stats', 'snapshots', 'scud', 'absences', 'anomalies', 'rules', 'prompts', 'sessions', 'dump', 'snapshot', 'tools', 'context'], help="Основная команда") + parser.add_argument('action', nargs='?', default=None, help="Дополнительное действие (например, 'del') или session_id для контекста") parser.add_argument('param', nargs='?', default=None, help="Параметр (дата, ID снапшота или имя файла)") parser.add_argument('--snapshot', type=str, default=None, help="ID конкретного снапшота для инспекции") parser.add_argument('--export-xlsx', type=str, default=None, help="Экспорт среза СКУД в Excel-файл") parser.add_argument('--day', type=str, default=None, help="Удалить снапшоты за конкретный день (ДД.ММ.ГГГГ)") + parser.add_argument('--limit', type=int, default=50, help="Лимит выводимых сообщений чата (для команды context)") if len(sys.argv) == 1: print_stats() @@ -1077,6 +1132,9 @@ def main(): print_session_states() elif args.command == 'tools': print_tool_actions() + elif args.command == 'context': + sess_id = args.action if args.action else None + print_chat_messages(session_id=sess_id, limit=args.limit) elif args.command == 'dump': filename = args.param if args.param else "db_dump_full.xlsx" dump_all_to_excel(filename) @@ -1449,15 +1507,29 @@ import os OUTPUT_SNAPSHOT = "project_code_snapshot.md" -# Расширения файлов для включения в снимок -ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini', '.js', '.html', '.css', '.sql'} -EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'} -EXCLUDE_FILES = {OUTPUT_SNAPSHOT, 'scud_orion_ai_v2.tar.gz', 'context_memory.db'} +# Расширения файлов для включения в снимок (только исходный код) +ALLOWED_EXTENSIONS = {'.py', '.json', '.sh', '.ini', '.js', '.html', '.css', '.sql'} -print(f"🔄 Сборка полного контекстного слепка проекта в {OUTPUT_SNAPSHOT}...") +# Исключаемые каталоги (убираем docs, кэши, архивы и окружения) +EXCLUDE_DIRS = { + '.git', '__pycache__', 'venv', '.venv', 'output', 'logs', + 'extracted_project', 'docs', 'data' +} + +# Исключаемые файлы +EXCLUDE_FILES = { + OUTPUT_SNAPSHOT, + 'api_code_snapshot.md', + 'project_code_snapshot.md', + 'scud_orion_ai_v2.tar.gz', + 'scud_context_api.tar.gz', + 'context_memory.db' +} + +print(f"🔄 Сборка оптимизированного слепка кода в {OUTPUT_SNAPSHOT}...") with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out: - out.write("# 📦 ПОЛНЫЙ ИСХОДНЫЙ КОД И КОНФИГУРАЦИЯ ПРОЕКТА scud_orion_ai_v2\n\n") + out.write("# 📦 ОПТИМИЗИРОВАННЫЙ СЛЕПОК ИСХОДНОГО КОДА scud_orion_ai\n\n") for root, dirs, files in os.walk('.'): dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS] @@ -1467,7 +1539,7 @@ with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out: if ext in ALLOWED_EXTENSIONS and file not in EXCLUDE_FILES: filepath = os.path.join(root, file) out.write(f"## File: `{filepath}`\n") - out.write("```" + (ext.replace('.', '') if ext != '.md' else '') + "\n") + out.write("```" + ext.replace('.', '') + "\n") try: with open(filepath, 'r', encoding='utf-8', errors='replace') as f: out.write(f.read()) @@ -1475,7 +1547,8 @@ with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out: out.write(f"// Ошибка чтения файла: {e}\n") out.write("\n```\n\n") -print(f"✓ Успешно создан слепок проекта: {OUTPUT_SNAPSHOT} ({os.path.getsize(OUTPUT_SNAPSHOT):,} bytes)") +size_kb = os.path.getsize(OUTPUT_SNAPSHOT) / 1024 +print(f"✓ Слепок успешно создан: {OUTPUT_SNAPSHOT} ({size_kb:.1f} KB)") ``` ## File: `./scripts/diagnostics/show_tree.py` @@ -3238,523 +3311,6 @@ def sync_zup_to_excel(target_date) -> bool: return False ``` -## File: `./docs/PROJECT BRAIN_ SCUD Orion AI & Context API (Master Manifesto v5.0).md` -``` -# **PROJECT BRAIN: SCUD Orion AI & Context API (Master Technical & Architectural Manifesto)** - -**Версия документа:** 5.0 (Ultimate Developer & LLM Context Engine) -**Назначение:** Исчерпывающий контекстный дамп проекта для немедленного продолжения разработки в любой передовой LLM без потери исторического контекста, с полным пониманием бизнес-логики, схем баз данных, подводных камней и нерешенных задач. - -## --- - -**1\. Архитектурный паспорт и инфраструктурный профиль** - -| Компонент | Параметры и стек | -| :---- | :---- | -| **Среда и ОС** | Linux Docker Home (Debian/Ubuntu), Python 3.11/3.12 в виртуальном окружении venv, Git/Gitea. Разработка ведется через VS Code Remote-SSH. | -| **Локальная СУБД проекта** | SQLite (data/scud\_orion\_ai.db), режим PRAGMA journal\_mode \= WAL, PRAGMA synchronous \= NORMAL, PRAGMA foreign\_keys \= ON. | -| **СКУД Источник (MSSQL)** | MS SQL Server 172.16.31.221\\SQL (база Orion-14.01.21-1), драйвер ODBC Driver 18 for SQL Server (TrustServerCertificate=yes, Encrypt=no). Доступ через pyodbc. | -| **1С:ЗУП 3.1 Источник (MSSQL)** | MS SQL Server ACCOUNT-01 (база ZUP30, пользователь scud\_reader). Прямое чтение таблиц dbo.\_Reference299 (сотрудники) и dbo.\_InfoRg16921 (состояния отсутствий). Резервная Samba-шара: /mnt/scud\_share. | -| **Локальный контур LLM** | Ollama на http://192.168.11.3:11434 (GPU NVIDIA RTX 5070 Ti 16GB). Модель qwen2.5:14b (основной агент, Function Calling, анализ аномалий), qwen2.5vl:7b-q8\_0 (OCR документов/картинок). | -| **Web API & Фронтенд** | FastAPI (Uvicorn), JWT (HS256, 30 дней), Passlib (bcrypt). Фронтенд: Single Page Application (HTML5, Tailwind CSS, Vanilla JS) с Generative UI виджетами. | - -## --- - -**2\. Энциклопедия подводных камней, инцидентов и принятых инженерных решений** - -### **2.1. Галлюцинации LLM и переход к парадигме Python-Driven Diff** - -> * **Проблема:** Попытка передать списки в 300 ФИО напрямую в промпт модели с командой «сравни списки» приводила к потере контекста, выдуманным опечаткам (*«ЛЕНМОРНИИПРОЕКТ ОЭС Варламова Мария»*) и плейсхолдерам ('Васильев Л.И.', ...). -> * **Решение:** Утверждена строгая гибридная схема: - 1. **Детерминированный слой (Python / Pandas / SQL):** читает файлы, нормализует строки, агрегирует совмещения и мгновенно находит 95%+ точных соответствий. - 2. **Интеллектуальный слой (Qwen 2.5:14b):** вызывается *только на спорный несовпавший остаток* (1–5 записей) в виде строгого JSON-интерфейса Entity Resolution. - -### **2.2. Символьная нормализация и кадровые совмещения (Кейс Балаевой О.Г.)** - -> * **Проблема символов:** Латинские омоглифы (буквы клавиатуры a, c, e, o, p, x, y вместо русских), разделители 1С в скобках ((осн.), (совм.), (внешн.)), неразрывные пробелы \\xa0, буква ё и случайный ввод цифры 0 вместо буквы О. -> * **Решение (normalize\_fio):** Очистка скобок через регулярное выражение re.sub(r'\\(.\*?\\)', '', fio), замена латиницы через str.maketrans('aceopxyABCEHKMOPTX', 'асеорхуАВСЕНКМОРТХ'), замена ё-\>е и удаление лишних пробелов. -> * **Коллизия 270 vs 271:** В СКУД 270 физических лиц (один пропуск на человека), в 1С — 271 строка. Сотрудник **Балаева Ольга Геннадьевна** числится на двух ставках в разных отделах (*Руководитель управления персоналом* и *Руководитель режимно-секретного отдела*). -> * **Решение:** Внедрена группировка датафрейма 1С по fio\_clean с агрегацией должностей и подразделений через разделитель " / ". - -### **2.3. Защита от коллизий полных тёзок и ложных связок однофамильцев (Кейс Семеновых)** - -> * **Инцидент:** Неограниченная строгими правилами LLM связала двух разных сотрудниц — *«Семенову Юлию Борисовну»* (работает) и *«Семенову Дину Дмитриевну»* (в отпуске), посчитав разные имена опечаткой из\-за одинаковой фамилии. В результате Юлии Борисовне ошибочно был приписан статус отпуска. -> * **Двухконтурный барьер:** - 1. **В промпте:** Запрет объединения однофамильцев с разными именами/отчествами. - 2. **В Python:** Проверка совпадения первых букв имени и отчества. При несовпадении сопоставление ИИ аннулируется с логированием ⚠️ \[Блокировка фейка\]. - -### **2.4. Вынесение исключений в exceptions.json и синонимы отделов** - -> * Служебный персонал (отдел «ЭТО», уборщики производственных и служебных помещений, клинеры, дворники, гардеробщики) вынесен в exceptions.json. -> * Для сопоставления кратких аббревиатур СКУД и полных названий отделов 1С (*«ОВК» ⟷ «Отдел внутреннего контроля»*) создана таблица department\_synonyms с автоматическим самообучением при работе человека (Human-in-the-Loop). - -### **2.5. Разгадка феномена «исчезающих неизвестных» и аппаратный сбой СКУД 04.08.2026** - -> * **Аномалия:** Утром 04.08.2026 на главном КПП зависли контроллеры входа. У 119 сотрудников не записалось утреннее событие (time\_in \= 'Нет входа'). Из них 57 человек были в отпусках/командировках по 1С. Оставшиеся 74 человека были на работе без утренней отметки. Однако вечерняя сводка показала всего 4–7 «неизвестных». -> * **Причина:** Скрипт агрегации видел дневные перемещения (обед, внутренние двери, туалет, вечерний выход) и автоматически проставлял Статус \= 'Присутствовал' и is\_present \= 1, несмотря на то, что колонка Начало\_дня оставалась 'Нет входа'. В итоге 70 человек тихо ушли в «Итого на работе», а системный сбой оборудования был замаскирован. -> * **Архитектурный фикс:** - 1. Введена колонка **first\_activity («Первая активность»)** — вычисление MIN(TimeVal) AS FirstRawEvent по таблице pLogData без учета направления двери. - 2. Если time\_in \== 'Нет входа', но first\_activity \!= '—', записи ставится anomaly\_flag \= 'ANOMALY\_NO\_IN\_HAS\_ACTIVITY', а is\_present принудительно сбрасывается в 0\. - 3. Внедрен модуль analyze\_scud\_mass\_failure\_ai(): если доля таких аномалий превышает 5% смены (\>10 чел.), ИИ генерирует системный алерт о массовом сбое контроллеров КПП. -> * **Кейс Равина В.Э.:** Главный специалист официально находился в основном отпуске по 1С, утреннего входа на КПП не было, но зафиксирована активность в 14:11:06 и вечерний выход в 17:18:38. Система зарегистрировала истинную кадровую аномалию (физическое присутствие при официальном отпуске). - -### **2.6. Система сквозных датированных снапшотов (срезов)** - -> * **Формат ID:** \[Y\]YYYYMMDD-NNN (например, Y20260804-001 для вчерашнего зафиксированного вечернего среза на 22:00 и 20260805-002 для дневного). -> * **Оптимизация:** Функция has\_yesterday\_final\_snapshot() проверяет наличие вчерашнего среза в SQLite: если он есть, повторный тяжелый запрос к MS SQL Орион пропускается. -> * **CLI-управление:** Поддержка запуска отчетов по любому историческому снапшоту через ключ \--snapshot \[ID\] и пропуск выгрузки через \--skip-export. - -### **2.7. Web API, Generative UI и управление жизненным циклом сессий (Topic Drift & Ephemeral Buffer)** - -> * **Проблема засорения контекста LLM:** Вывод объемных списков задач, системных промптов и таблиц срезов забивал окно диалога, вызывая галлюцинации и «залипание» модели на старых данных. -> * **Решение:** - * В chat\_messages введен флаг is\_ephemeral \= 1 для временных сообщений. - * Двухфазные сессии (PROMPT\_PREVIEW → PROMPT\_FOLLOWUP) и детерминированный счетчик idle\_turns. - * **Context Guard:** На 3-м шаге отвлечения оператора на сторонние темы система отвечает на вопрос и выводит интерактивное напоминание с кнопками \[Завершить настройку\] / \[Показать промпт\]. При idle\_turns \> 3 происходит автоочистка сессии вызовом db\_purge\_ephemeral\_messages(). - * **Fast-Path:** Прямой перехват команд подтверждения («да», «подтверждаю») и выхода («нет, спасибо») без обращения к LLM (ответ за 0.05 сек). - * **Декларативный реестр (tool\_action\_registry):** Хранение шаблонов ответов, кнопок и настроек эфемерности в SQLite. -> * **Generative UI (Задачи):** Инструмент db\_get\_tasks возвращает TASK\_INTERACTIVE\_CARD, рендерящий на клиенте живой виджет задач с табами (Все / В работе / В планах / Завершенные), чекбоксами и inline-созданием задач через REST API. - -## --- - -**3\. Полная схема базы данных SQLite (data/scud\_orion\_ai.db)** - -| Таблица | Назначение и бизнес-логика | Структура полей | -| :---- | :---- | :---- | -| **scud\_logs** | Логи проходов СКУД, расчетное время в здании и снапшоты. | id INTEGER PK AUTOINCREMENT, log\_date TEXT, fio TEXT, fio\_clean TEXT, department TEXT, position TEXT, time\_in TEXT, first\_activity TEXT DEFAULT '—', time\_out TEXT, time\_in\_building TEXT, is\_present INTEGER, anomaly\_flag TEXT DEFAULT 'NONE', snapshot\_time TEXT, snapshot\_id TEXT, created\_at TIMESTAMP | -| **zup\_staff** | Штатное расписание 1С:ЗУП на даты срезов. | id INTEGER PK AUTOINCREMENT, snapshot\_date TEXT, fio TEXT, fio\_clean TEXT, department TEXT, position TEXT, created\_at TIMESTAMP | -| **zup\_absences** | Документы отклонений 1С (отпуска, больничные, командировки, декреты). | id INTEGER PK AUTOINCREMENT, absence\_date TEXT, fio TEXT, fio\_clean TEXT, absence\_type TEXT, created\_at TIMESTAMP | -| **anomalies\_history** | Журнал выявленных кадровых и аппаратных аномалий. | id INTEGER PK AUTOINCREMENT, anomaly\_date TEXT, fio TEXT, anomaly\_type TEXT, details TEXT, human\_status TEXT DEFAULT 'Pending', created\_at TIMESTAMP | -| **ai\_knowledge\_base** | Глобальные правила арбитража и приоритеты компании для ИИ. | id INTEGER PK AUTOINCREMENT, rule\_text TEXT UNIQUE, added\_by TEXT DEFAULT 'Human', created\_at TIMESTAMP | -| **system\_prompts** | Версионные системные промпты агента (активный по is\_active=1). | id INTEGER PK AUTOINCREMENT, name TEXT, prompt\_text TEXT, is\_active INTEGER DEFAULT 1, updated\_at TIMESTAMP | -| **tool\_action\_registry** | Декларативный реестр шаблонов, кнопок и настроек эфемерности инструментов. | id INTEGER PK AUTOINCREMENT, tool\_name TEXT UNIQUE, category TEXT, bypass\_llm INTEGER, success\_template TEXT, follow\_up\_question TEXT, action\_type TEXT, buttons\_json TEXT, is\_active INTEGER, is\_ephemeral INTEGER, updated\_at TIMESTAMP | -| **session\_states** | Активные состояния сессий, черновики и счетчик idle\_turns. | session\_id TEXT PRIMARY KEY, state\_type TEXT, pending\_data TEXT, updated\_at TIMESTAMP | -| **chat\_messages** | История диалогов с поддержкой разделения на постоянные и эфемерные сообщения. | id INTEGER PK AUTOINCREMENT, session\_id TEXT, role TEXT, content TEXT, is\_ephemeral INTEGER DEFAULT 0, created\_at TIMESTAMP | -| **tasks** | Реестр задач операторов с привязкой к user\_id. | id INTEGER PK AUTOINCREMENT, task\_id TEXT, module TEXT, title TEXT, priority TEXT, status TEXT, due\_date TEXT, user\_id INTEGER, created\_at TIMESTAMP | -| **users** | Учетные записи операторов (хэши паролей bcrypt, права администратора). | id INTEGER PK AUTOINCREMENT, username TEXT UNIQUE, password\_hash TEXT, is\_admin INTEGER, full\_name TEXT, created\_at TIMESTAMP | -| **department\_synonyms** | База соответствия аббревиатур СКУД полным названиям отделов 1С. | id INTEGER PK AUTOINCREMENT, short\_name TEXT UNIQUE, full\_name TEXT, created\_at TIMESTAMP | -| **system\_reference** | Справочник команд и примеров промптов для операторов. | id INTEGER PK AUTOINCREMENT, category TEXT, title TEXT, example\_prompt TEXT, description TEXT | - -## --- - -**4\. Архитектурная структура каталогов** - -scud\_ai/ -├── config.py \# Конфигурация: пути, расчет дат, URL Ollama, параметры MS SQL -├── exceptions.json \# Декларативные фильтры исключений (ОВК, уборщики, подрядчики) -├── main\_etl.py \# Оркестратор ETL: экспорт СКУД/1С \-\> SQLite \-\> Excel/MD отчеты -├── project\_code\_snapshot.md \# Полный монолитный исходный код всех файлов репозитория -├── core/ -│ └── database.py \# Драйвер SQLite (WAL), функции сохранения/загрузки срезов -├── services/ -│ ├── scud\_export.py \# Прямой SQL-экспорт из MS SQL «Орион Pro» -│ ├── zup\_extractor.py \# Прямой SQL-экспорт из MS SQL 1С:ЗУП 3.1 -│ ├── data\_loader.py \# Загрузка данных, нормализация ФИО, расчет первой активности -│ ├── data\_validator.py \# Проверка свежести и наличия файлов за Вчера и Сегодня -│ ├── excel\_exporter.py \# Генерация сводки и детального отчета в Excel (openpyxl) -│ ├── text\_reporter.py \# Построение итогового Markdown-отчета через Qwen 2.5 -│ ├── ai\_verifier.py \# Модули ИИ-аудита, Fuzzy Matching и детекции сбоев СКУД -│ ├── feedback\_loop.py \# Интерактивный консольный модуль Human-in-the-Loop -│ ├── knowledge\_base.py \# Интерфейс работы с базой знаний правил -│ └── share\_copier.py \# Резервное скачивание файлов 1С с шары /mnt/scud\_share -├── scripts/ -│ ├── db\_cli.py \# CLI-утилита управления БД, снапшотами, дампами и сессиями -│ ├── fix\_snapshots.py \# Скрипт переиндексации и нормализации снапшотов -│ ├── init\_tool\_registry.py \# Инициализация декларативного реестра инструментов -│ └── diagnostics/ -│ ├── inspect\_db.py \# Диагностика структуры таблиц и количества записей -│ ├── inspect\_ephemeral.py \# Анализ распределения постоянных и эфемерных сообщений -│ ├── inspect\_files.py \# Аудит файлов и размеров на диске -│ └── show\_tree.py \# Вывод дерева каталогов -├── modules/web\_api/ -│ ├── main.py \# Точка входа FastAPI приложения, роутеры и раздача статики -│ ├── routers/ (admin, auth, chat, tasks) \# REST эндпоинты -│ ├── llm/ -│ │ ├── agent.py \# Координатор диалога, Tool Calls и Topic Drift Guard -│ │ ├── schemas.py \# Описание JSON-схем инструментов для Ollama -│ │ ├── file\_parser.py \# Мультимодальный парсер файлов (PDF, PNG, XLSX, TXT) -│ │ ├── db\_tools.py \# Фасадные вызовы функций БД для агента -│ │ ├── core/ (calendar\_utils, fast\_path, ollama\_client, prompt\_merger, tool\_injector) -│ │ └── db/ (connection, db\_chat, db\_prompts, db\_snapshots, db\_tasks) -│ └── static/ (index.html, css/styles.css, js/app.js, js/auth.js, js/chat.js, js/tasks.js) -└── docs/ - └── development\_roadmap.md \# Стратегическая дорожная карта и архитектурный бэклог - -## --- - -**5\. Справочник команд CLI (HOWTO)** - -### **ETL Пайплайн (main\_etl.py):** - -> * python main\_etl.py — стандартный дневной запуск (экспорт из MS SQL, сохранение снапшота, генерация отчетов). -> * python main\_etl.py \--skip-export — построение отчетов по последнему имеющемуся снапшоту из SQLite без повторного запроса к MS SQL. -> * python main\_etl.py \--snapshot 20260805-001 — точный расчет отчетов по указанному составному ID снапшота. -> * python main\_etl.py \-d — запуск в режиме расширенной отладки (DEBUG). - -### **CLI базы данных (scripts/db\_cli.py):** - -> * python scripts/db\_cli.py stats — статистика по количеству строк во всех таблицах БД. -> * python scripts/db\_cli.py snapshots \[ДД.ММ.ГГГГ\] — просмотр реестра снапшотов с сортировкой по времени создания (свежие сверху). -> * python scripts/db\_cli.py scud \[ДД.ММ.ГГГГ\] \[--snapshot ID\] \[--export-xlsx NAME\] — инспекция логов СКУД и экспорт в Excel. -> * python scripts/db\_cli.py absences \[ДД.ММ.ГГГГ\] — список кадровых отклонений из 1С:ЗУП. -> * python scripts/db\_cli.py anomalies — история выявленных аномалий. -> * python scripts/db\_cli.py rules — правила базы знаний компании. -> * python scripts/db\_cli.py prompts — системные промпты из system\_prompts. -> * python scripts/db\_cli.py tools — декларативный реестр действий инструментов и кнопок. -> * python scripts/db\_cli.py sessions — активные сессионные стейты и висящие черновики. -> * python scripts/db\_cli.py snapshot del \[ID\] — удаление конкретного снапшота по ID. -> * python scripts/db\_cli.py snapshot del \--day \[ДД.ММ.ГГГГ\] — удаление всех снапшотов за день. -> * python scripts/db\_cli.py dump \[backup.xlsx\] — полный дамп всех таблиц базы данных в многостраничный Excel. - -## --- - -**6\. Стратегическая дорожная карта и нерешенные задачи (Roadmap & Backlog)** - -> 1. **Масштабирование механизма эфемерного контекста на все инструменты (Rollout Roadmap):** - * **Снапшоты (snapshots):** Перевод вывода объемных таблиц срезов в эфемерный режим (is\_ephemeral \= 1), внедрение интерактивного подтверждения при удалении снапшотов по ID/дате через состояния SNAPSHOTS\_PREVIEW / SNAPSHOTS\_FOLLOWUP. - * **База знаний и аномалии (kb & anomalies):** Вывод справочников команд, правил арбитража и списков аномалий исключительно во временный слой для исключения «залипания» модели на устаревших правилах из истории сообщений. - * **Декларативная унификация в tool\_action\_registry:** Расширение схемы таблицы полями followup\_state\_type, auto\_purge\_on\_exit, guard\_question\_template для устранения хардкода имен функций в agent.py. -> 2. **Модуль анализа внутридневных перемещений («Контроль перекуров»):** - * Анализ интервалов между событиями OUT (выход) и IN (возврат) по журналу pLogData. - * Подсчет суммарного времени нахождения вне рабочего места в течение смены с отсечением технологической нормы (30–40 минут). - * Вывод аналитического блока и злостных нарушителей в отдельный лист Excel-отчета и итоговую Markdown-сводку. -> 3. **Интерактивный Task Planner & Roadmap Engine:** - * Специализированный инструмент для ведения динамических проектных планов и чек-листов с чекбоксами (\[ \] / \[x\]) прямо в диалоге с синхронизацией в таблицу tasks. -> 4. **Изолированная среда исполнения кода (Code Execution Sandbox):** - * Безопасный изолированный Docker/gVisor контейнер для динамического выполнения генерируемого моделью Python/Pandas кода на лету с возвратом stdout/stderr и графиков в контекст. -> 5. **Автоматизация расписания срезов и Telegram-алерты:** - * Автозапуск выгрузок в 12:00, 17:00 и 19:00 через systemd-таймеры с фиксацией snapshot\_time. - * Отправка мгновенных алертов о массовых сбоях турникетов (\>5%) и критических кадровых конфликтах дежурному системному администратору в Telegram. -``` - -## File: `./docs/SCUD Orion AI — Полная энциклопедическая хроника, архитектурный паспорт и технический контекст (v4.0).md` -``` -# **SCUD Orion AI — Полная энциклопедическая хроника, архитектурный паспорт, разбор всех инцидентов и технический контекст проекта** - -**Версия документа:** 4.0 (Исчерпывающий монолитный контекстный паспорт для новой сессии) -**Стек технологий и инфраструктура:** Python 3.11+, FastAPI (Uvicorn), SQLite (WAL mode, PRAGMA synchronous=NORMAL, внешние ключи ON), Ollama REST API (Qwen 2.5:14b — основной текстовый агент и классификатор, Qwen 2.5-VL:7b-q8\_0 / Qwen 3-VL:8b — OCR и зрение), MS SQL Server 2019/2022 (СКУД «Орион Pro» ЗАО НВП «Болид», 1С:ЗУП 3.1 через pyodbc / ODBC Driver 18), сетевая Samba-шара (/mnt/scud\_share), Git/Gitea, HTML5 / Vanilla JS / Tailwind CSS. - -## --- - -**1\. Исходный бизнес-контекст, роли систем и фундаментальные задачи** - -Проект решает комплексную проблему контроллинга трудовой дисциплины, автоматического сопоставления кадровых фактов и защиты от рассинхронизации мастер-данных на предприятии со штатом \~270–276 сотрудников. - -### **Источники первичных данных:** - -> * **СКУД «Орион Pro» (Болид):** База данных MS SQL Server (SERVER: 172.16.31.221\\SQL, DB: Orion-14.01.21-1). Основные таблицы: - * pLogData — журнал всех сырых физических транзакций турникетов, калиток и внутренних дверей. Ключевые поля: TimeVal (время сервера), DeviceTime (время контроллера), HozOrgan / Hrk (ID сотрудника), Event (код события: 26, 28, 32 — вход; 27, 29, 33 — выход; 54, 55, 64, 65 — внутренние считыватели), Mode (направление: 1 — IN, 2 — OUT), ReaderIndex, DoorIndex. - * pList — реестр физических лиц и пропусков (ФИО, табельный номер, статус архива). - * PDivision, PPost — справочники подразделений и должностей СКУД. -> * **1С:ЗУП 3.1 (Кадры):** База данных MS SQL Server (SERVER: ACCOUNT-01, DB: ZUP30). - * dbo.\_Reference299 — эталонный справочник сотрудников. - * dbo.\_InfoRg16921 — периодический регистр сведений состояний сотрудников (отпуска основные, больничные, командировки, декретные отпуска, отпуска без сохранения оплаты). - * Файловые выгрузки с сетевой шары /mnt/scud\_share: файлы Штат\_ДД\_ММ\_ГГГГ.xlsx (шапка 7–8 строк) и Отсутствия\_ДД\_ММ\_ГГГГ.xlsx (шапка 3–4 строки). -> * **Реестры статических исключений:** static\_reason\_workers.csv (реестр удаленщиков и постоянных разъездных сотрудников), exceptions.json (декларативный фильтр обслуживающего персонала и спец-отделов). - -## --- - -**2\. Подробнейшая пошаговая хронология разработки, эволюция идей и разбор ошибок** - -### **Фаза 1: Первые эксперименты с LLM, выявление галлюцинаций и гибридная парадигма** - -> * **Попытка передачи сырых списков в чат:** Первоначальная попытка скормить модели Qwen 2.5:14b список из сотен ФИО с инструкцией *«запомни список 1С»* показала, что у LLM нет постоянной памяти между сессиями. Модель теряла контекст, путала фамилии и выдавала ответы с троеточиями ('Васильев Л.И.', ...) или придумывала несуществующие строки (*«ЛЕНМОРНИИПРОЕКТ ОЭС Варламова Мария»*). -> * **Ошибки openpyxl vs pandas:** Первая генерация кода моделью использовала библиотеку openpyxl с построчным перебором ячеек, жестко зашитыми опечатками (поиск слова 'фиио') и падением по FileNotFoundError. -> * **Переход к детерминированному пайплайну:** Принято ключевое архитектурное решение — **LLM не считает математику и не фильтрует 300 строк текста**. Всю механическую работу (ETL, слияние, фильтрацию) выполняет быстрый детерминированный Python (pandas, SQL), а локальная LLM выступает исключительно в роли «умного ревизора» на этапе сопоставления сомнительного несовпавшего остатка (Diff). - -### **Фаза 2: Нормализация ФИО, кодировки и коллизия совместителей (Кейс Балаевой О.Г.)** - -> * **Символьные ловушки:** - * Латинские буквы-двойники (омоглифы a, c, e, o, p, x, y, случайно набранные в английской раскладке). - * Служебные суффиксы в скобках, добавляемые 1С ((осн.), (совм.), (внешн.)). - * Буквы ё/е, двойные пробелы, неразрывные пробелы \\xa0 и случайный ввод цифры 0 вместо буквы О. -> * **Функция normalize\_fio():** Разработан строгий конвейер очистки: -> `def normalize_fio(fio):` -> `if not fio or not isinstance(fio, str): return ""` -> `fio_clean = re.sub(r'\(.*?\)', '', fio) # Удаление скобок` -> `fio_clean = fio_clean.replace('\xa0', ' ')` -> `trans = str.maketrans('aceopxyABCEHKMOPTX', 'асеорхуАВСЕНКМОРТХ')` -> `fio_clean = fio_clean.translate(trans)` -> `fio_clean = fio_clean.replace('ё', 'е').replace('Ё', 'Е')` -> `return " ".join(fio_clean.strip().split()).title()` -> * **Коллизия 270 vs 271:** В СКУД числилось 270 уникальных человек, а в 1С — 271 строка. Ручной разбор показал, что **Балаева Ольга Геннадьевна** занимает две ставки в 1С (*Руководитель управления персоналом* и *Руководитель режимно-секретного отдела*) при одном физическом пропуске в СКУД. -> * **Решение:** Внедрена обязательная группировка штата 1С по fio\_clean с объединением должностей и подразделений через " / ". - -### **Фаза 3: Защита Entity Resolution от однофамильцев (Инцидент с Семеновыми)** - -> * **Сбой модели:** При передаче несовпавших ФИО в Qwen 2.5 модель ошибочно сопоставила *«Семенову Юлию Борисовну»* (которая была на работе) с *«Семеновой Диной Дмитриевной»* (находившейся в отпуске), посчитав это опечаткой в имени из\-за одинаковой фамилии. В результате Юлия Борисовна получила статус нарушителя («в отпуске, но пришла»). -> * **Двухконтурная защита:** - 1. В системный промпт ИИ зашито категорическое правило: однофамильцы с разными именами/отчествами — строго разные люди, объединение запрещено. - 2. В Python встроен дублирующий барьер: если фамилии совпадают, но инициалы/первые буквы имен различаются, связь ИИ принудительно аннулируется с логированием ⚠️ \[Блокировка фейка\]. - -### **Фаза 4: Декларативные исключения и синонимы отделов** - -> * **Переход к exceptions.json:** Исключение клининга, дворников, гардеробщиков и хозяйственного персонала отдела «ЭТО» вынесено из кода в конфигурационный файл exceptions.json (поддержка списков departments, positions, fio, position\_keywords). -> * **База синонимов отделов (department\_synonyms):** Для сопоставления сокращений СКУД с длинными названиями 1С (например, *«ОВК» ⟷ «Отдел внутреннего контроля»*) создана таблица SQLite, автоматически обучаемая при подтверждениях оператора. - -### **Фаза 5: Аппаратный сбой СКУД 04.08.2026, разгадка «исчезающих неизвестных» и Первая активность** - -> * **Анатомия сбоя:** Утром 04.08.2026 зависли регистраторы входа на главном КПП. У 119 сотрудников не записалось событие входа (time\_in \= 'Нет входа'). Из них 57 человек были официально закрыты документами 1С (отпуска, командировки). Оставшиеся \~74 человека реально пришли на работу, но утренней отметки не имели. Однако вечером сводка выдала всего 4–7 «неизвестных». -> * **Вскрытие механизма ошибки:** Было обнаружено, что скрипт агрегации видел дневные перемещения сотрудников (выходы на обед, считыватели туалетов, внутренние двери) и автоматически выставлял Статус \= 'Присутствовал' и is\_present \= 1\. Из-за этого люди попадали в категорию «Итого на работе», а массовый аппаратный сбой скрывался. -> * **Внедрение «Первой активности»:** - 1. В базу данных и выгрузки Excel добавлена колонка **first\_activity («Первая активность»)** — минимальное время любого события дня в здании (MIN(TimeVal) AS FirstRawEvent без учета направления двери). - 2. Если time\_in \== 'Нет входа', но first\_activity \!= '—', записи присваивается anomaly\_flag \= 'ANOMALY\_NO\_IN\_HAS\_ACTIVITY', а is\_present принудительно сбрасывается в 0\. - 3. Внедрен модуль analyze\_scud\_mass\_failure\_ai(): при превышении порога в 5% смены (\>10 человек) ИИ поднимает тревогу о массовом сбое контроллеров КПП. -> * **Кейс Равина В.Э.:** Сотрудник находился в официальном отпуске, утреннего входа не имел, но пришел после обеда (первая активность 14:11:06, выход 17:18:38). Система корректно зафиксировала истинную кадровую аномалию (физическое присутствие при официальном отпуске). - -### **Фаза 6: Архитектурная миграция под Gitea и система срезов (снапшотов)** - -> * **Безопасный рефакторинг:** Монолит разделен на слои (core/, services/, scripts/, modules/web\_api/) через git mv с сохранением истории коммитов в ветке refactor/modular-structure. -> * **Формат ID снапшота:** Введена схема \[Y\]YYYYMMDD-NNN (например, Y20260804-001 для вчерашнего зафиксированного вечернего среза на 22:00 и 20260805-002 для дневного). -> * **Оптимизация экспорта:** Если в SQLite уже зафиксирован вчерашний срез с префиксом Y, повторный тяжелый SQL-запрос к СКУД Орион не выполняется (функция has\_yesterday\_final\_snapshot). -> * **Инспекция и CLI:** Написан инструмент scripts/db\_cli.py с командами stats, snapshots, scud, absences, anomalies, rules, prompts, tools, sessions, dump, snapshot del. - -### **Фаза 7: Web API, Generative UI, управление памятью LLM и Topic Drift Guard** - -> * **FastAPI & Безопасность:** Развернут Web API с JWT-авторизацией (bcrypt), изолированными задачами пользователей (tasks с привязкой к user\_id), гостевым режимом и ролевой моделью. -> * **Проблема деградации контекста LLM:** Вывод объемных списков задач, текстов системных промптов и таблиц снапшотов засорял контекстное окно чата, приводя к галлюцинациям и «залипанию» модели на старых правилах. -> * **Решение проблемы памяти (Topic Drift & Ephemeral Context):** - * В таблицу chat\_messages добавлен флаг is\_ephemeral \= 1 для временных сообщений. - * Введены двухфазные сессии (PROMPT\_PREVIEW → PROMPT\_FOLLOWUP) и детерминированный счетчик отвлечений idle\_turns. - * **Context Guard:** На 3-м шаге отвлечения оператора на сторонние темы система отвечает на вопрос и выводит интерактивное напоминание с кнопками \[Завершить настройку\] / \[Показать промпт\]. При idle\_turns \> 3 происходит автоочистка сессии вызовом db\_purge\_ephemeral\_messages(). - * **Fast-Path:** Прямой перехват команд подтверждения («да», «подтверждаю», «сохранить») и выхода («нет, спасибо», «закончить настройку») без обращения к LLM (ответ за 0.05 сек). - * **Декларативный реестр действий (tool\_action\_registry):** Хранение шаблонов ответов, типов кнопок и признаков эфемерности в SQLite. -> * **Generative UI (Интерактивные карточки задач):** Инструмент db\_get\_tasks возвращает полезную нагрузку TASK\_INTERACTIVE\_CARD. Фронтенд рендерит живой виджет с табами (Все / В работе / В планах / Завершенные), чекбоксами и inline-созданием задач через REST API без вызова LLM. - -## --- - -**3\. Полная схема базы данных SQLite (data/scud\_orion\_ai.db)** - -| Таблица | Назначение и бизнес-логика | Полная структура колонок | -| :---- | :---- | :---- | -| **scud\_logs** | Хранение логов проходов СКУД, расчетного времени и снапшотов. | id (PK), log\_date, fio, fio\_clean, department, position, time\_in, first\_activity, time\_out, time\_in\_building, is\_present, anomaly\_flag, snapshot\_time, snapshot\_id, created\_at | -| **zup\_staff** | Штатное расписание 1С:ЗУП на даты срезов. | id (PK), snapshot\_date, fio, fio\_clean, department, position, created\_at | -| **zup\_absences** | Официальные кадровые отклонения 1С (отпуска, больничные, командировки, декреты). | id (PK), absence\_date, fio, fio\_clean, absence\_type, created\_at | -| **anomalies\_history** | Журнал выявленных кадровых и аппаратных аномалий. | id (PK), anomaly\_date, fio, anomaly\_type, details, human\_status, created\_at | -| **ai\_knowledge\_base** | Глобальные правила арбитража и приоритеты компании для ИИ. | id (PK), rule\_text, added\_by, created\_at | -| **system\_prompts** | Версионные системные промпты агента (активный промпт по is\_active=1). | id (PK), name, prompt\_text, is\_active, updated\_at | -| **tool\_action\_registry** | Декларативный реестр шаблонов, кнопок и настроек эфемерности инструментов. | id (PK), tool\_name, category, bypass\_llm, success\_template, follow\_up\_question, action\_type, buttons\_json, is\_active, is\_ephemeral, updated\_at | -| **session\_states** | Активные состояния сессий, черновики и счетчик idle\_turns. | session\_id (PK), state\_type, pending\_data, updated\_at | -| **chat\_messages** | История диалогов с поддержкой разделения на постоянные и эфемерные сообщения. | id (PK), session\_id, role, content, is\_ephemeral, created\_at | -| **tasks** | Реестр задач пользователей с привязкой к user\_id. | id (PK), task\_id, module, title, priority, status, due\_date, user\_id, created\_at | -| **users** | Учетные записи операторов (хэши паролей bcrypt, права администратора). | id (PK), username, password\_hash, is\_admin, full\_name, created\_at | -| **department\_synonyms** | База знаний соответствия аббревиатур СКУД полным названиям отделов 1С. | id (PK), short\_name, full\_name, created\_at | -| **system\_reference** | Справочник команд и примеров промптов для операторов. | id (PK), category, title, example\_prompt, description | - -## --- - -**4\. Актуальная архитектура каталогов и назначение файлов** - -`scud_ai/` -`├── config.py # Конфигурация: пути, расчет дат, URL Ollama, параметры MS SQL` -`├── exceptions.json # Декларативные фильтры исключений (ОВК, уборщики, подрядчики)` -`├── main_etl.py # Оркестратор ETL: экспорт СКУД/1С -> SQLite -> Excel/MD отчеты` -`├── project_code_snapshot.md # Полный исходный код всех файлов репозитория` -`├── core/` -`│ └── database.py # Драйвер SQLite (WAL), функции сохранения/загрузки срезов` -`├── services/` -`│ ├── scud_export.py # Прямой SQL-экспорт из MS SQL «Орион Pro»` -`│ ├── zup_extractor.py # Прямой SQL-экспорт из MS SQL 1С:ЗУП 3.1` -`│ ├── data_loader.py # Загрузка данных, нормализация ФИО, расчет первой активности` -`│ ├── data_validator.py # Проверка свежести и наличия файлов за Вчера и Сегодня` -`│ ├── excel_exporter.py # Генерация сводки и детального отчета в Excel (openpyxl)` -`│ ├── text_reporter.py # Построение итогового Markdown-отчета через Qwen 2.5` -`│ ├── ai_verifier.py # Модули ИИ-аудита, Fuzzy Matching и детекции сбоев СКУД` -`│ ├── feedback_loop.py # Интерактивный консольный модуль Human-in-the-Loop` -`│ ├── knowledge_base.py # Интерфейс работы с базой знаний правил` -`│ └── share_copier.py # Резервное скачивание файлов 1С с шары /mnt/scud_share` -`├── scripts/` -`│ ├── db_cli.py # CLI-утилита управления БД, снапшотами, дампами и сессиями` -`│ ├── fix_snapshots.py # Скрипт переиндексации и нормализации снапшотов` -`│ ├── init_tool_registry.py # Инициализация декларативного реестра инструментов` -`│ └── diagnostics/` -`│ ├── inspect_db.py # Диагностика структуры таблиц и количества записей` -`│ ├── inspect_ephemeral.py # Анализ распределения постоянных и эфемерных сообщений` -`│ ├── inspect_files.py # Аудит файлов и размеров на диске` -`│ └── show_tree.py # Вывод дерева каталогов` -`├── modules/web_api/` -`│ ├── main.py # Точка входа FastAPI приложения, роутеры и раздача статики` -`│ ├── routers/` -`│ │ ├── admin.py # Управление пользователями` -`│ │ ├── auth.py # JWT аутентификация и смена паролей` -`│ │ ├── chat.py # Эндпоинты диалогов (авторизованный и гостевой)` -`│ │ └── tasks.py # REST API управления задачами` -`│ ├── llm/` -`│ │ ├── agent.py # Координатор диалога, Tool Calls и Topic Drift Guard` -`│ │ ├── schemas.py # Описание JSON-схем инструментов для Ollama` -`│ │ ├── file_parser.py # Мультимодальный парсер файлов (PDF, PNG, XLSX, TXT)` -`│ │ ├── db_tools.py # Фасадные вызовы функций БД для агента` -`│ │ ├── core/` -`│ │ │ ├── calendar_utils.py # Формирование системного контекста дат и дней недели` -`│ │ │ ├── fast_path.py # Быстрый перехват команд подтверждения/отмены (0.05 с)` -`│ │ │ ├── ollama_client.py # HTTP-клиент взаимодействия с Ollama API` -`│ │ │ ├── prompt_merger.py # Парсинг и слияние правок системного промпта` -`│ │ │ └── tool_injector.py # Очистка сырых тегов и артефактов Tool Calls` -`│ │ └── db/` -`│ │ ├── connection.py # Подключение к scud_orion_ai.db` -`│ │ ├── db_chat.py # Сохранение истории и очистка эфемерных сообщений` -`│ │ ├── db_prompts.py # Системные промпты, стейты и реестр действий` -`│ │ ├── db_snapshots.py # Выборка и удаление снапшотов` -`│ │ └── db_tasks.py # CRUD операции с задачами пользователей` -`│ └── static/` -`│ ├── index.html # SPA разметка (модалки, чат, боковая панель задач)` -`│ ├── css/styles.css # Стили и мобильные оптимизации` -`│ └── js/` -`│ ├── app.js # Инициализация приложения` -`│ ├── auth.js # Логика входа, смены паролей и администрирования` -`│ ├── chat.js # Обработка сообщений, Drag&Drop, виджет задач и кнопки` -`│ └── tasks.js # Управление боковой панелью задач (Drawer)` -`└── docs/` - `└── development_roadmap.md # Стратегическая дорожная карта и архитектурный бэклог` - -## --- - -**5\. Справочник команд командной строки (CLI Reference)** - -### **Главный конвейер контроллинга (main\_etl.py)** - -> * python main\_etl.py — стандартный дневной запуск: экспорт свежих данных СКУД и 1С, сохранение снапшота в SQLite, генерация Excel и Markdown отчетов. -> * python main\_etl.py \--skip-export — построение отчетов по последнему имеющемуся снапшоту из SQLite без повторного запроса к MS SQL. -> * python main\_etl.py \--snapshot 20260805-001 — точный расчет отчетов по указанному составному ID снапшота. -> * python main\_etl.py \-d — запуск в режиме расширенной отладки (DEBUG). - -### **CLI-утилита управления SQLite базой данных (scripts/db\_cli.py)** - -> * python scripts/db\_cli.py stats — общая статистика количества записей по всем таблицам БД. -> * python scripts/db\_cli.py snapshots \[ДД.ММ.ГГГГ\] — просмотр реестра снапшотов с сортировкой по времени создания (самые свежие сверху). -> * python scripts/db\_cli.py scud \[ДД.ММ.ГГГГ\] \[--snapshot ID\] \[--export-xlsx NAME\] — просмотр логов СКУД и опциональный экспорт в Excel. -> * python scripts/db\_cli.py absences \[ДД.ММ.ГГГГ\] — список кадровых отклонений из 1С:ЗУП. -> * python scripts/db\_cli.py anomalies — история выявленных аномалий. -> * python scripts/db\_cli.py rules — правила базы знаний компании. -> * python scripts/db\_cli.py prompts — просмотр системных промптов из таблицы system\_prompts. -> * python scripts/db\_cli.py tools — вывод декларативного реестра действий инструментов и шаблонов кнопок. -> * python scripts/db\_cli.py sessions — просмотр активных сессионных стейтов и висящих черновиков. -> * python scripts/db\_cli.py snapshot del \[ID\] — удаление конкретного снапшота по ID. -> * python scripts/db\_cli.py snapshot del \--day \[ДД.ММ.ГГГГ\] — удаление всех снапшотов за выбранный день. -> * python scripts/db\_cli.py dump \[backup.xlsx\] — полный дамп всех таблиц базы данных в многостраничный Excel-файл. - -## --- - -**6\. Стратегическая дорожная карта развития (Roadmap & Backlog)** - -> 1. **Масштабирование механизма эфемерного контекста на все инструменты (Rollout Roadmap):** - * **Снапшоты (snapshots):** Перевод вывода объемных таблиц срезов в эфемерный режим (is\_ephemeral \= 1), внедрение интерактивного подтверждения при удалении снапшотов по ID/дате через состояния SNAPSHOTS\_PREVIEW / SNAPSHOTS\_FOLLOWUP. - * **База знаний и аномалии (kb & anomalies):** Вывод справочников команд, правил арбитража и списков аномалий исключительно во временный слой для исключения «залипания» модели на устаревших правилах из истории сообщений. - * **Декларативная унификация в tool\_action\_registry:** Расширение схемы таблицы полями followup\_state\_type, auto\_purge\_on\_exit, guard\_question\_template для устранения хардкода имен функций в agent.py. -> 2. **Модуль анализа внутридневных перемещений («Контроль перекуров»):** - * Анализ интервалов между событиями OUT (выход) и IN (возврат) по журналу pLogData. - * Подсчет суммарного времени нахождения вне рабочего места в течение смены с отсечением технологической нормы (30–40 минут). - * Вывод аналитического блока и злостных нарушителей в отдельный лист Excel-отчета и итоговую Markdown-сводку. -> 3. **Интерактивный Task Planner & Roadmap Engine:** - * Специализированный инструмент для ведения динамических проектных планов и чек-листов с чекбоксами (\[ \] / \[x\]) прямо в диалоге с синхронизацией в таблицу tasks. -> 4. **Изолированная среда исполнения кода (Code Execution Sandbox):** - * Безопасный изолированный Docker/gVisor контейнер для динамического выполнения генерируемого моделью Python/Pandas кода на лету с возвратом stdout/stderr и графиков в контекст. -> 5. **Автоматизация расписания срезов и Telegram-алерты:** - * Автозапуск выгрузок в 12:00, 17:00 и 19:00 через systemd-таймеры с фиксацией snapshot\_time. - * Отправка мгновенных алертов о массовых сбоях турникетов (\>5%) и критических кадровых конфликтах дежурному системному администратору в Telegram. -``` - -## File: `./docs/development_roadmap.md` -``` -# 🗺️ ДОРОЖНАЯ КАРТА И АРХИТЕКТУРНЫЙ РОАДМАП ПРОЕКТА SCUD ORION AI - -## 📌 Зафиксированные вехи и концептуальные архитектурные решения - -Схема веток Gitea для ближайшего развития проекта: - -main (100% стабильный релиз) - │ - ├──► feature/tool-registry-db ──────┐ (Создание таблицы tool_action_registry в SQLite) - │ ▼ - ├───────────────────────────────► merge to main - │ - ├──► feature/interactive-buttons ───┐ (Подключение Да/Нет чипсов на фронтенде) - │ ▼ - ├───────────────────────────────► merge to main - │ - └──► feature/ephemeral-context ─────┐ (Внедрение флагов is_ephemeral для очистки памяти LLM) - ▼ - merge to main - - - - -### 1. Архитектура скользящего контекста и отслеживания смены темы (Intent / Topic Drift Tracking) -* **Проблема:** При работе с длинными операциями (редактирование промптов, мастера табелей, снапшотов) оператор может отвлекаться на сторонние вопросы или уточнения. Жесткий сброс сессии уничтожает черновики, а вечное хранение засоряет память LLM. -* **Решение (Сферическое / Фундаментальное):** - 1. **Семантическая оценка моделью:** Модель через системный контекст определяет, относится ли вопрос оператора к активному черновику/инструменту или разговор ушел в сторону (`topic_shift = true`). - 2. **Детерминированный счетчик в бэкенде:** Бэкенд фиксирует шаги отвлечения (`idle_turns`). Пока $N < 3$, оператор может свободно общаться, не теряя висящий контекст. - 3. **Вежливый перехват и выбор (Guard):** На 3-м шаге отвлечения система отвечает на текущий вопрос оператора и мягко напоминает о незавершенной транзакции кнопками `[Применить / Сохранить]` или `[Отменить и сбросить]`. - ---- - -## 🚀 Будущие модули и запланированный функционал (Backlog) - -### 2. Интерактивный модуль планирования и дорожных карт (Checklist & Task Planner Engine) -* **Концепция:** Специализированный инструмент для LLM-агента, позволяющий вести динамические проектные чек-листы и планы с интерактивными чекбоксами прямо в диалоге и базе данных. -* **Ключевые возможности:** - * Составление многоуровневых планов с чекбоксами (`[ ]` / `[x]`). - * Фиксация промежуточных комментариев и статусов выполнения по каждому пункту. - * Синхронизация задач плана с SQLite-таблицей `tasks`. - * Экспорт планов в Markdown/Excel и отображение на дашборде. - - -### 3. Изолированная среда выполнения и песочница кода (Code Execution Sandbox Engine) -* **Концепция:** Безопасный изолированный Docker-контейнер (или gVisor / Pyodide / nsjail окружение) для динамического выполнения кода, генерируемого моделью в процессе рассуждений и анализа данных. -* **Ключевые возможности:** - * Запуск сложных вычислений, агрегаций и статистического анализа данных СКУД на лету (Pandas / NumPy). - * Выполнение тестовых сценариев и валидация скриптов перед их сохранением/применением на проде. - * Полная изоляция от хост-системы: read-only доступ к копиям данных, ограничение памяти/CPU (cgroups), отсутствие доступа к чувствительным сетевым интерфейсам. - * Возврат результатов вычислений (stdout, stderr, артефакты, сгенерированные таблицы/графики) обратно в контекст модели. - - -### 1. Архитектура скользящего контекста и отслеживания смены темы (Intent / Topic Drift Tracking) -* **Статус:** `MVP Реализован и протестирован` (на базе инструментов системного промпта: `db_preview_prompt_merge`, `db_confirm_prompt_preview`, `db_cancel_prompt_preview`). -* **Концептуальное решение:** - 1. **Двухфазное состояние сессии:** Разделение жизненного цикла на фазу черновика (`PROMPT_PREVIEW`) и фазу интерактивного подтверждения/диалога (`PROMPT_FOLLOWUP`). - 2. **Эфемерный буфер:** Промежуточные экраны, подтверждения и тяжелые выводы помечаются `is_ephemeral = 1` и не засоряют постоянную историю диалога. - 3. **Детерминированный счетчик (Topic Drift):** Бэкенд инкрементирует `idle_turns` при отвлечении на сторонние темы. На шаге $N = 3$ срабатывает **Context Guard** с напоминанием и кнопками действий, а при $N > 3$ происходит автоматический сброс сессии и вызов `db_purge_ephemeral_messages`. - 4. **Fast-Path очистка:** Прямой перехват команд завершения (`нет, спасибо`, `закончить настройку`) моментально очищает эфемерный контекст. - ---- - -### 1.1. План масштабирования механизма эфемерного контекста на все инструменты (Rollout Roadmap) - -План: - -[1. REST API (main.py)] ────► Добавляем POST, PATCH, DELETE для /api/v1/tasks - │ -[2. Agent (agent.py)] ────► При db_get_tasks отдаем payload {type: "TASK_INTERACTIVE_CARD", tasks: [...]} - │ -[3. UI (chat.js / CSS)] ────► Рендерим карточку с табами [Все] [В работе] [В планах] [Завершенные] - -#### Этап 1. Модуль задач и бэклога (`tasks`) -* **Целевые инструменты:** `db_get_tasks`, `db_add_task`, `db_update_task_status`, `db_delete_task`. -* **Задачи реализации:** - * Перевод вывода объемных плоских списков задач в эфемерный режим (`is_ephemeral = 1`), чтобы история диалога не забивалась длинными перечнями. - * Реализация состояния сессии `TASKS_FOLLOWUP` после создания/изменения/удаления задачи. - * Интеграция с Context Guard: напоминание о завершении работы с задачами на 3-м шаге отвлечения. - * Автоочистка промежуточных диалоговых карточек задач при переключении на аналитику СКУД или общие вопросы. - -#### Этап 2. Модуль срезов и архива СКУД (`snapshots`) -* **Целевые инструменты:** `db_get_snapshots`, `db_delete_snapshots`. -* **Задачи реализации:** - * Маркировка объемных таблиц срезов и логов как эфемерных сообщений. - * Внедрение интерактивного подтверждения при удалении снапшотов по ID или по дате (`SNAPSHOTS_PREVIEW` / `SNAPSHOTS_FOLLOWUP`). - * Полная автоочистка временных таблиц срезов из памяти диалога после получения пользователем нужной информации. - -#### Этап 3. Модуль базы знаний, правил и аномалий (`kb & anomalies`) -* **Целевые инструменты:** `db_get_rules`, `db_get_anomalies`, `db_get_reference`. -* **Задачи реализации:** - * Вывод справочников команд, правил арбитража и списков аномалий исключительно во временный слой. - * Предотвращение «залипания» модели на старых правилах из истории: гарантия того, что LLM всегда запрашивает актуальные правила через Tool Call, а не читает их из контекста предыдущих сообщений. - -#### Этап 4. Декларативная унификация в `tool_action_registry` -* **Целевые изменения:** - * Расширение схемы `tool_action_registry` новыми декларативными полями: `followup_state_type`, `auto_purge_on_exit`, `guard_question_template`. - * Устранение жестких проверок названий функций в `agent.py`: перевод всей логики жизненного цикла сессий и очистки памяти на декларативные параметры из базы данных SQLite. -``` - ## File: `./core/__init__.py` ```py @@ -4331,17 +3887,21 @@ async def favicon(): def serve_static_fallback(file_path: str): clean_path = file_path.lstrip("/") target = os.path.join(STATIC_DIR, clean_path) + if os.path.isfile(target): + if clean_path.endswith(".js"): + return FileResponse(target, media_type="application/javascript") + elif clean_path.endswith(".css"): + return FileResponse(target, media_type="text/css") return FileResponse(target) + # Резервный рекурсивный поиск файла в static filename = os.path.basename(clean_path) - target_js = os.path.join(STATIC_DIR, "js", filename) - if filename.endswith(".js") and os.path.isfile(target_js): - return FileResponse(target_js, media_type="application/javascript") - - target_css = os.path.join(STATIC_DIR, "css", filename) - if filename.endswith(".css") and os.path.isfile(target_css): - return FileResponse(target_css, media_type="text/css") + for root, _, files in os.walk(STATIC_DIR): + if filename in files: + full_path = os.path.join(root, filename) + media = "application/javascript" if filename.endswith(".js") else "text/css" + return FileResponse(full_path, media_type=media) raise HTTPException(status_code=404, detail="File not found") ``` @@ -4390,119 +3950,6 @@ if __name__ == "__main__": clear_chat_history() ``` -## File: `./modules/web_api/scripts/diagnostics/inspect_db.py` -```py -import os -import sqlite3 - -# Автопоиск файла базы данных в проекте -db_path = '/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db' if os.path.exists('/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db') else 'scud_orion_ai.db' - -print("=" * 80) -print(f"🔍 ДИАГНОСТИКА СУБД SQLITE: {db_path}") -print("=" * 80) - -if not os.path.exists(db_path): - print(f"❌ Файл базы данных {db_path} не найден!") - exit(1) - -conn = sqlite3.connect(db_path) -cursor = conn.cursor() - -# 1. Список всех таблиц и колонок -cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") -tables = [t[0] for t in cursor.fetchall()] - -print("\n📋 СТРУКТУРА ТАБЛИЦ И КОЛИЧЕСТВО ЗАПИСЕЙ:") -print("-" * 80) -for t_name in tables: - cursor.execute(f"PRAGMA table_info({t_name})") - cols = [c[1] for c in cursor.fetchall()] - - cursor.execute(f"SELECT COUNT(*) FROM {t_name}") - count = cursor.fetchone()[0] - - print(f"• [{t_name:<20}] — {count:>6} строк | Колонки: {cols}") - -# 2. Просмотр правил Базы Знаний -if 'ai_knowledge_base' in tables: - print("\n" + "=" * 80) - print("🧠 АКТУАЛЬНЫЕ ПРАВИЛА БАЗЫ ЗНАНИЙ (ai_knowledge_base):") - print("=" * 80) - cursor.execute("SELECT id, rule_text, added_by FROM ai_knowledge_base ORDER BY id ASC") - rules = cursor.fetchall() - if not rules: - print("Таблица ai_knowledge_base пуста.") - else: - for r_id, r_text, r_author in rules: - print(f" {r_id}. [{r_author}] {r_text}\n") - -conn.close() -print("=" * 80) -``` - -## File: `./modules/web_api/scripts/diagnostics/inspect_files.py` -```py -import os - -print("=" * 80) -print("📂 ТЕКУЩЕЕ СОСТОЯНИЕ ФАЙЛОВ ПРОЕКТА (scud_orion_context)") -print("=" * 80) - -total_files = 0 -total_size = 0 - -for root, dirs, files in os.walk('.'): - # Исключаем служебные каталоги - dirs[:] = [d for d in dirs if d not in ['.git', '__pycache__', 'venv', '.venv', 'extracted_project']] - - for f in files: - p = os.path.join(root, f) - size = os.path.getsize(p) - total_files += 1 - total_size += size - print(f"{p:<55} ({size:>10,} bytes)".replace(',', ' ')) - -print("-" * 80) -print(f"ИТОГО: файлов: {total_files} | Общий объем: {total_size / (1024 * 1024):.2f} MB") -print("=" * 80) -``` - -## File: `./modules/web_api/scripts/diagnostics/make_code_snapshot.py` -```py -import os - -OUTPUT_SNAPSHOT = "api_code_snapshot.md" - -# Расширения файлов для включения в снимок -ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini', '.js', '.html', '.css'} -EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'} -EXCLUDE_FILES = {OUTPUT_SNAPSHOT, 'scud_context_api.tar.gz', 'context_memory.db'} - -print(f"🔄 Сборка полного контекстного слепка проекта в {OUTPUT_SNAPSHOT}...") - -with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out: - out.write("# 📦 ПОЛНЫЙ ИСХОДНЫЙ КОД И КОНФИГУРАЦИЯ ПРОЕКТА scud_context_api\n\n") - - for root, dirs, files in os.walk('.'): - dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS] - - for file in sorted(files): - ext = os.path.splitext(file)[1].lower() - if ext in ALLOWED_EXTENSIONS and file not in EXCLUDE_FILES: - filepath = os.path.join(root, file) - out.write(f"## File: `{filepath}`\n") - out.write("```" + (ext.replace('.', '') if ext != '.md' else '') + "\n") - try: - with open(filepath, 'r', encoding='utf-8', errors='replace') as f: - out.write(f.read()) - except Exception as e: - out.write(f"// Ошибка чтения файла: {e}\n") - out.write("\n```\n\n") - -print(f"✓ Успешно создан слепок проекта: {OUTPUT_SNAPSHOT} ({os.path.getsize(OUTPUT_SNAPSHOT):,} bytes)") -``` - ## File: `./modules/web_api/scripts/diagnostics/show_tree.py` ```py import os @@ -4786,9 +4233,11 @@ if __name__ == "__main__":
+ - + + @@ -5886,6 +5335,661 @@ function renderTasks() { } ``` +## File: `./modules/web_api/static/js/chat/core.js` +```js +/** +=============================================================================== +FILE: modules/web_api/static/js/chat/core.js +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / static / js / chat +ROLE: Клиентский интерфейс диалога, Drag-and-Drop вложений, блокировка ввода + при активных кнопках, вызов онлайн-редактора и отправка правок. +=============================================================================== +*/ + +function updateInputHeight(el) { + if (!el) return; + el.style.height = "24px"; + const newHeight = Math.min(el.scrollHeight, 120); + el.style.height = newHeight + "px"; +} + +let selectedFile = null; + +function handleFileSelect(e) { + const file = e.target.files[0]; + if (!file) return; + + if (file.size > 15 * 1024 * 1024) { + alert("Файл слишком большой. Максимальный размер: 15 МБ"); + e.target.value = ""; + return; + } + + selectedFile = file; + const fileNameEl = document.getElementById("file-name-display"); + const fileSizeEl = document.getElementById("file-size-display"); + const previewContainer = document.getElementById("file-preview-container"); + + if (fileNameEl) fileNameEl.innerText = file.name; + if (fileSizeEl) fileSizeEl.innerText = `(${(file.size / 1024).toFixed(1)} KB)`; + if (previewContainer) previewContainer.classList.remove("hidden"); +} + +function clearAttachedFile() { + selectedFile = null; + const fileInput = document.getElementById("file-input"); + const previewContainer = document.getElementById("file-preview-container"); + if (fileInput) fileInput.value = ""; + if (previewContainer) previewContainer.classList.add("hidden"); +} + +function setInputLocked(isLocked) { + const input = document.getElementById("user-input"); + const sendBtn = document.getElementById("send-btn"); + if (input) { + input.disabled = isLocked; + if (isLocked) { + input.placeholder = "Выберите действие с помощью кнопок выше..."; + input.classList.add("cursor-not-allowed", "opacity-60", "bg-slate-200/50"); + } else { + input.placeholder = "Команда, вопрос или перетащите файл сюда..."; + input.classList.remove("cursor-not-allowed", "opacity-60", "bg-slate-200/50"); + } + } + if (sendBtn) { + sendBtn.disabled = isLocked; + if (isLocked) { + sendBtn.classList.add("opacity-40", "cursor-not-allowed"); + } else { + sendBtn.classList.remove("opacity-40", "cursor-not-allowed"); + } + } +} + +function disableAllActionButtons() { + const allBtnContainers = document.querySelectorAll(".action-buttons-container"); + allBtnContainers.forEach(container => { + container.querySelectorAll("button").forEach(btn => { + btn.disabled = true; + btn.classList.add("opacity-40", "cursor-not-allowed"); + }); + }); +} + +function handleActionButtonClick(text) { + if (text === "action:open_editor") { + openInlinePromptEditor(); + return; + } + + disableAllActionButtons(); + setInputLocked(false); + const input = document.getElementById("user-input"); + if (input) { + input.value = text; + sendMessage(); + } +} + +function openInlinePromptEditor() { + const editorContainer = document.getElementById("inline-prompt-editor-container"); + if (editorContainer) { + editorContainer.classList.remove("hidden"); + const textarea = document.getElementById("inline-prompt-textarea"); + if (textarea) textarea.focus(); + } +} + +function closeInlinePromptEditor() { + const editorContainer = document.getElementById("inline-prompt-editor-container"); + if (editorContainer) editorContainer.classList.add("hidden"); +} + +async function saveManualPromptDraft() { + const textarea = document.getElementById("inline-prompt-textarea"); + if (!textarea) return; + + const newText = textarea.value.trim(); + if (!newText) return; + + const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); + + try { + const res = await fetch("/api/v1/chat/draft", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer " + token + }, + body: JSON.stringify({ + session_id: "web_session_main", + draft_text: newText + }) + }); + + if (res.ok) { + closeInlinePromptEditor(); + const previewTextEl = document.getElementById("prompt-preview-diff-view"); + if (previewTextEl) { + previewTextEl.innerText = newText; + } + alert("✓ Правки сохранены в черновике! Нажмите «Подтвердить» для применения в БД."); + } + } catch (err) { + alert("Ошибка сохранения черновика: " + err); + } +} + +function escapeHtml(text) { + if (!text) return ""; + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +// --- [CHAT SEND PIPELINE] --- +async function sendMessage(e) { + if (e && e.preventDefault) e.preventDefault(); + + const input = document.getElementById("user-input"); + const chatWindow = document.getElementById("chat-window"); + const sendBtn = document.getElementById("send-btn"); + + if (!input || !chatWindow) return; + const text = input.value.trim(); + + if (!text && !selectedFile) return; + + disableAllActionButtons(); + + let userDisplayHtml = escapeHtml(text); + if (selectedFile) { + userDisplayHtml = `
+ ${escapeHtml(selectedFile.name)} +
` + userDisplayHtml; + } + + const userMsgHtml = ` +
+
+ ${userDisplayHtml} +
+
+ `; + chatWindow.insertAdjacentHTML("beforeend", userMsgHtml); + + input.value = ""; + updateInputHeight(input); + chatWindow.scrollTop = chatWindow.scrollHeight; + + if (sendBtn) { + sendBtn.disabled = true; + sendBtn.classList.add("opacity-50"); + } + + const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); + const isGuest = typeof IS_GUEST !== 'undefined' ? IS_GUEST : (localStorage.getItem("scud_is_guest") === "true"); + + const endpoint = isGuest ? "/api/v1/chat/guest" : "/api/v1/chat"; + + const formData = new FormData(); + formData.append("session_id", "web_session_main"); + formData.append("message", text || "Проанализируй прикрепленный файл"); + + if (selectedFile instanceof File) { + formData.append("file", selectedFile, selectedFile.name); + } + + const headers = {}; + if (!isGuest && token) { + headers["Authorization"] = "Bearer " + token; + } + + try { + const res = await fetch(endpoint, { + method: "POST", + headers: headers, + body: formData + }); + + if (res.status === 401 && !isGuest) { + if (typeof logout === 'function') logout(); + return; + } + + const data = await res.json(); + const assistantTitle = isGuest ? "Локальная нейросеть (Гость)" : "ИИ-Ассистент SCUD Orion AI"; + const replyText = data.reply || "Пустой ответ от нейросети"; + + let actionButtonsHtml = ""; + const actionData = data.action_type || data.action; + + if (actionData && actionData.buttons && actionData.buttons.length > 0) { + const buttonsMarkup = actionData.buttons.map(btn => { + let btnClasses = "bg-slate-100 hover:bg-slate-200 active:bg-slate-300 text-slate-700 border border-slate-300"; + let iconMarkup = ''; + + const labelLower = (btn.label || "").toLowerCase(); + const isPrimary = btn.style === "primary" || labelLower.includes("подтверд") || labelLower.startsWith("да"); + const isDanger = btn.style === "danger" || labelLower.includes("отмен") || labelLower.startsWith("нет") || labelLower.includes("законч") || labelLower.includes("заверш"); + const isEdit = labelLower.includes("редактир"); + + if (isPrimary) { + btnClasses = "bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white shadow-sm"; + iconMarkup = ''; + } else if (isDanger) { + btnClasses = "bg-rose-50 hover:bg-rose-100 active:bg-rose-200 text-rose-700 border border-rose-300"; + iconMarkup = ''; + } else if (isEdit) { + btnClasses = "bg-indigo-50 hover:bg-indigo-100 active:bg-indigo-200 text-indigo-700 border border-indigo-300"; + iconMarkup = ''; + } + + return ` + + `; + }).join(""); + + actionButtonsHtml = ` +
+ ${buttonsMarkup} +
+ `; + + // Блокируем ввод при наличии кнопок + setInputLocked(true); + } else { + setInputLocked(false); + } + + let previewEditorHtml = ""; + if (actionData && actionData.type === "PROMPT_PREVIEW" && actionData.raw_draft) { + previewEditorHtml = ` + + `; + } + + let interactiveWidgetHtml = ""; + if (actionData && actionData.type === "TASK_INTERACTIVE_CARD" && typeof renderInteractiveTaskCard === "function") { + interactiveWidgetHtml = renderInteractiveTaskCard(actionData.tasks); + } + + const botMsgHtml = ` +
+

+ ${assistantTitle} +

+
${replyText}
+ ${previewEditorHtml} + ${interactiveWidgetHtml} + ${actionButtonsHtml} +
+ `; + chatWindow.insertAdjacentHTML("beforeend", botMsgHtml); + chatWindow.scrollTop = chatWindow.scrollHeight; + + clearAttachedFile(); + + if (!isGuest && typeof loadTasks === 'function') { + loadTasks(); + } + + } catch (err) { + console.error("[Chat Error]", err); + const errorHtml = ` +
+ Ошибка связи с сервером. +
+ `; + chatWindow.insertAdjacentHTML("beforeend", errorHtml); + chatWindow.scrollTop = chatWindow.scrollHeight; + setInputLocked(false); + } finally { + const inputEl = document.getElementById("user-input"); + if (sendBtn && (!inputEl || !inputEl.disabled)) { + sendBtn.disabled = false; + sendBtn.classList.remove("opacity-50"); + } + } +} + +// --- [DRAG & DROP AND KEYBOARD LISTENERS] --- +document.addEventListener("DOMContentLoaded", () => { + const input = document.getElementById("user-input"); + const dropZone = document.getElementById("chat-window")?.parentElement; + const dropOverlay = document.getElementById("drop-overlay"); + + if (input) { + let historyIndex = -1; + let localHistory = JSON.parse(localStorage.getItem("scud_chat_input_history") || "[]"); + + input.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + if (input.disabled) return; + const text = input.value.trim(); + if (text) { + if (localHistory.length === 0 || localHistory[0] !== text) { + localHistory.unshift(text); + if (localHistory.length > 50) localHistory.pop(); + localStorage.setItem("scud_chat_input_history", JSON.stringify(localHistory)); + } + historyIndex = -1; + } + sendMessage(e); + updateInputHeight(input); + return; + } + + if (e.key === "ArrowUp") { + const textBeforeCursor = input.value.substring(0, input.selectionStart); + const isFirstLine = !textBeforeCursor.includes("\n"); + + if (isFirstLine && input.selectionStart === 0 && localHistory.length > 0) { + if (historyIndex < localHistory.length - 1) { + e.preventDefault(); + if (historyIndex === -1) { + input.dataset.draft = input.value; + } + historyIndex++; + input.value = localHistory[historyIndex]; + updateInputHeight(input); + input.setSelectionRange(input.value.length, input.value.length); + } + } + } + + if (e.key === "ArrowDown") { + const textAfterCursor = input.value.substring(input.selectionEnd); + const isLastLine = !textAfterCursor.includes("\n"); + + if (isLastLine && input.selectionEnd === input.value.length && historyIndex >= 0) { + e.preventDefault(); + if (historyIndex > 0) { + historyIndex--; + input.value = localHistory[historyIndex]; + } else { + historyIndex = -1; + input.value = input.dataset.draft || ""; + } + updateInputHeight(input); + input.setSelectionRange(input.value.length, input.value.length); + } + } + }); + } + + if (dropZone && dropOverlay) { + ["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => { + dropZone.addEventListener(eventName, (e) => { + e.preventDefault(); + e.stopPropagation(); + }, false); + }); + + ["dragenter", "dragover"].forEach(eventName => { + dropZone.addEventListener(eventName, () => { + dropOverlay.classList.remove("hidden"); + dropOverlay.classList.add("flex"); + }, false); + }); + + ["dragleave", "drop"].forEach(eventName => { + dropZone.addEventListener(eventName, (e) => { + if (eventName === "drop" || e.target === dropZone || !dropZone.contains(e.relatedTarget)) { + dropOverlay.classList.add("hidden"); + dropOverlay.classList.remove("flex"); + } + }, false); + }); + + dropZone.addEventListener("drop", (e) => { + const dt = e.dataTransfer; + const files = dt.files; + + if (files && files.length > 0) { + const file = files[0]; + handleFileSelect({ target: { files: [file] } }); + + const fileInput = document.getElementById("file-input"); + if (fileInput) { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + fileInput.files = dataTransfer.files; + } + } + }, false); + } +}); +``` + +## File: `./modules/web_api/static/js/chat/task_widget.js` +```js +/** +=============================================================================== +FILE: modules/web_api/static/js/chat/task_widget.js +ROLE: Генеративный UI интерактивных карточек задач внутри диалога чата + (фильтры, inline-чекбоксы статусов, быстрое добавление и удаление). +=============================================================================== +*/ + +let activeWidgetTasksMap = new Map(); + +function renderTaskItemsHtml(tasks, filterStatus) { + const filtered = tasks.filter(t => { + if (filterStatus === "ALL") return true; + return t.status === filterStatus; + }); + + if (filtered.length === 0) { + const label = filterStatus === 'BACKLOG' ? 'В планах' : filterStatus === 'IN_PROGRESS' ? 'В работе' : filterStatus === 'COMPLETED' ? 'Завершенные' : 'Все'; + return `
Нет задач со статусом «${label}»
`; + } + + return filtered.map(t => { + const isDone = t.status === "COMPLETED"; + const isProgress = t.status === "IN_PROGRESS"; + const checkedAttr = isDone ? "checked" : ""; + const textClass = isDone ? "line-through text-slate-400" : "text-slate-800 font-medium"; + + let statusPill = `В планах`; + if (isDone) { + statusPill = `Завершено`; + } else if (isProgress) { + statusPill = `В работе`; + } + + let prioPill = ""; + if (t.priority === "HIGH") { + prioPill = `HIGH`; + } + + return ` +
+
+ +
+
+ ${t.task_id} + ${prioPill} + ${statusPill} +
+ ${escapeHtml(t.title)} +
+
+ +
+ `; + }).join(""); +} + +function renderInteractiveTaskCard(tasks) { + if (!tasks || !Array.isArray(tasks)) return ""; + const widgetId = "task_widget_" + Date.now(); + activeWidgetTasksMap.set(widgetId, { tasks: tasks, filter: "ALL" }); + + const itemsHtml = renderTaskItemsHtml(tasks, "ALL"); + + return ` +
+
+ + + + +
+ +
+ ${itemsHtml} +
+ +
+ + +
+
+ `; +} + +function filterTaskWidget(widgetId, status, btnEl) { + const state = activeWidgetTasksMap.get(widgetId); + if (!state) return; + state.filter = status; + + const widgetEl = document.getElementById(widgetId); + if (!widgetEl) return; + + widgetEl.querySelectorAll(".widget-tab-btn").forEach(b => { + b.className = "widget-tab-btn px-2.5 py-1 rounded-lg text-slate-500 hover:text-slate-700"; + }); + btnEl.className = "widget-tab-btn px-2.5 py-1 rounded-lg bg-indigo-50 text-indigo-600 font-bold border border-indigo-200"; + + const container = widgetEl.querySelector(".widget-items-container"); + if (container) { + container.innerHTML = renderTaskItemsHtml(state.tasks, status); + } +} + +async function toggleTaskStatusInline(taskId, isChecked) { + const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); + const newStatus = isChecked ? "COMPLETED" : "BACKLOG"; + + try { + const res = await fetch(`/api/v1/tasks/${taskId}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer " + token + }, + body: JSON.stringify({ status: newStatus }) + }); + + if (res.ok) { + for (let [wId, state] of activeWidgetTasksMap.entries()) { + const targetTask = state.tasks.find(t => t.task_id === taskId); + if (targetTask) { + targetTask.status = newStatus; + const widgetEl = document.getElementById(wId); + const container = widgetEl?.querySelector(".widget-items-container"); + if (container) { + container.innerHTML = renderTaskItemsHtml(state.tasks, state.filter); + } + } + } + if (typeof loadTasks === "function") loadTasks(); + } + } catch (err) { + console.error("[Task Patch Error]", err); + } +} + +async function deleteTaskInline(taskId) { + if (!confirm(`Удалить задачу ${taskId}?`)) return; + const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); + + try { + const res = await fetch(`/api/v1/tasks/${taskId}`, { + method: "DELETE", + headers: { "Authorization": "Bearer " + token } + }); + + if (res.ok) { + for (let [wId, state] of activeWidgetTasksMap.entries()) { + state.tasks = state.tasks.filter(t => t.task_id !== taskId); + const widgetEl = document.getElementById(wId); + const container = widgetEl?.querySelector(".widget-items-container"); + if (container) { + container.innerHTML = renderTaskItemsHtml(state.tasks, state.filter); + } + } + if (typeof loadTasks === "function") loadTasks(); + } + } catch (err) { + console.error("[Task Delete Error]", err); + } +} + +async function addTaskInline(widgetId, inputEl) { + if (!inputEl) return; + const title = inputEl.value.trim(); + if (!title) return; + + const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); + + try { + const res = await fetch("/api/v1/tasks", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer " + token + }, + body: JSON.stringify({ title: title, priority: "MEDIUM", module: "general" }) + }); + + if (res.ok) { + inputEl.value = ""; + const tasksRes = await fetch("/api/v1/tasks", { + headers: { "Authorization": "Bearer " + token } + }); + const updatedTasks = await tasksRes.json(); + + const state = activeWidgetTasksMap.get(widgetId); + if (state) { + state.tasks = updatedTasks; + const widgetEl = document.getElementById(widgetId); + const container = widgetEl?.querySelector(".widget-items-container"); + if (container) { + container.innerHTML = renderTaskItemsHtml(updatedTasks, state.filter); + } + } + if (typeof loadTasks === "function") loadTasks(); + } + } catch (err) { + console.error("[Task Add Error]", err); + } +} +``` + ## File: `./modules/web_api/static/css/styles.css` ```css /* Плавное исчезновение текста сверху при скролле */ @@ -6119,20 +6223,31 @@ def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = D """ =============================================================================== FILE: modules/web_api/routers/chat.py -ROLE: Маршрутизация диалогов с LLM (авторизованный и гостевой чаты). +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / routers +ROLE: Маршрутизация диалогов с LLM и эндпоинт сохранения онлайн-черновиков. =============================================================================== """ # ANCHOR[CHAT_ROUTER_IMPORTS] from typing import Optional, Dict, Any -from fastapi import APIRouter, Depends, UploadFile, File, Form +from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException +from pydantic import BaseModel from .auth import get_current_user from llm.agent import process_chat_message from llm.file_parser import extract_text_from_file +from llm.db_tools import db_set_session_state, db_get_session_state router = APIRouter(prefix="/api/v1/chat", tags=["chat"]) + +# ANCHOR[DRAFT_SCHEMA] +class UpdateDraftRequest(BaseModel): + session_id: str + draft_text: str + + # ANCHOR[CHAT_ENDPOINTS] @router.post("") async def chat_endpoint( @@ -6156,6 +6271,7 @@ async def chat_endpoint( ) return {"reply": reply, "history": history, "action_type": action_type} + @router.post("/guest") async def guest_chat_endpoint( session_id: str = Form("web_session_main"), @@ -6176,6 +6292,20 @@ async def guest_chat_endpoint( session_id=session_id ) return {"reply": reply, "history": history, "action_type": action_type} + + +@router.post("/draft") +def update_draft_endpoint( + req: UpdateDraftRequest, + current_user: Dict[str, Any] = Depends(get_current_user) +): + """Обновляет черновик системного промпта напрямую из интерактивной онлайн-формы.""" + state = db_get_session_state(req.session_id) + if not state or state.get("state_type") != "PROMPT_PREVIEW": + raise HTTPException(status_code=400, detail="Нет активного превью для редактирования") + + db_set_session_state(req.session_id, "PROMPT_PREVIEW", {"draft_text": req.draft_text.strip()}) + return {"status": "success", "message": "Черновик успешно обновлен в сессии"} ``` ## File: `./modules/web_api/routers/tasks.py` @@ -6265,20 +6395,19 @@ def delete_task_endpoint(task_id: str, user: Dict[str, Any] = Depends(get_curren FILE: modules/web_api/llm/agent.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: web_api / llm (Core Agent Coordinator) -ROLE: Оркестратор диалога и диспетчер Function Calling. +ROLE: Оркестратор диалога, диспетчер реляционных узлов промпта и инструментов. =============================================================================== """ - -# --- [SECTION 1: SYSTEM PATHS & IMPORTS] --- # ANCHOR[SYS_IMPORTS] import sys import json import logging +import re from typing import List, Dict, Any, Tuple, Optional from .db.connection import get_db_connection from .db_tools import ( db_get_active_system_prompt, - db_add_system_prompt, + db_apply_prompt_node_action, db_get_tool_action, db_get_tasks, db_update_task_status, @@ -6296,18 +6425,15 @@ from .db_tools import ( db_purge_ephemeral_messages, db_get_stats, db_get_anomalies, - db_get_session_states, db_get_reference ) from .schemas import TOOLS_SCHEMA from .core.calendar_utils import get_dynamic_calendar_context -from .core.tool_injector import clean_raw_tool_tags, clean_output +from .core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed from .core.ollama_client import call_ollama_chat from .core.fast_path import handle_fast_path_intercept -from .core.prompt_merger import build_prompt_preview_merge -# --- [SECTION 2: LOGGING CONFIGURATION] --- # ANCHOR[LOGGING_CONFIG] logger = logging.getLogger("SCUD_AGENT") logger.setLevel(logging.INFO) logger.propagate = False @@ -6319,7 +6445,6 @@ if not logger.handlers: logger.addHandler(handler) -# --- [SECTION 3: MAIN CHAT PROCESSING PIPELINE] --- # ANCHOR[CHAT_PROCESSOR] def process_chat_message( user_id: int, user_message: str, @@ -6328,18 +6453,13 @@ def process_chat_message( chat_history: List[Dict[str, Any]] = None, session_id: str = "web_session_main" ) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]: - """ - Главный конвейер диалога: - 1. Проверка Fast-Path кнопок (подтверждение, отмена, завершение с очисткой). - 2. Формирование актуального системного контекста и вызов Ollama LLM. - 3. Выполнение инструментов и фиксация в БД. - """ + """Главный конвейер диалога с нативным Function Calling и реляционными узлами.""" logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}") full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message session_state = db_get_session_state(session_id) - # 1. Fast-Path перехват + # 1. Fast-Path перехват кнопок fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state) if fast_path_res: return fast_path_res @@ -6349,40 +6469,42 @@ def process_chat_message( db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=is_user_ephemeral) db_history = db_get_chat_history(session_id, limit=20) - # 3. Сборка системного промпта + # 3. Сборка системного контекста dynamic_prompt_text = db_get_active_system_prompt() calendar_context = get_dynamic_calendar_context() user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость" active_state_context = "" - if session_state: - state_type = session_state.get("state_type", "GENERAL") + if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": active_state_context = ( - f"\n[АКТИВНЫЙ РЕЖИМ СЕССИИ: {state_type}]\n" - f"Сейчас активен сценарий: {state_type}.\n" - f"- При запросе на изменение данных вызывай соответствующий инструмент.\n" + "\n[ВНИМАНИЕ: АКТИВНЫЙ РЕЖИМ ПРЕДПРОСМОТРА ПРОМПТА]\n" + "- Сейчас открыт предпросмотр изменения системного промпта.\n" + "- Если пользователь просит изменить, скорректировать формулировку или удалить пункт — вызови инструмент db_prompt_node_edit.\n" + "- Если пользователь переключился на другую тему (задачи, логи, аномалии, общие вопросы) — вежливо напомни ему, что открыто несохраненное превью, и спроси, желает ли он сначала применить или отменить правку промпта.\n" ) system_prompt_content = ( f"Ты — интеллектуальный ассистент SCUD Orion AI. " f"Ты управляешь задачами, аналитикой СКУД и системными настройками с помощью инструментов (tools).\n\n" f"СТРОГИЕ ПРАВИЛА СТИЛЯ:\n" - f"- Запрещено использовать панибратские или шутливые обращения (например, 'Почемучка'). Отвечай профессионально и строго по существу.\n\n" + f"- Запрещено использовать панибратские или шутливые обращения. Отвечай профессионально и строго по существу.\n\n" f"[ОКРУЖЕНИЕ]\n" f"- Пользователь: {user_info}\n" f"- {calendar_context}\n" f"{active_state_context}\n" f"[ПРАВИЛА ИСПОЛЬЗОВАНИЯ ИНСТРУМЕНТОВ]\n" - f"1. Для любых изменений системного промпта ВСЕГДА вызывай db_preview_prompt_merge(prompt_text=...).\n" + f"1. Для ЛЮБЫХ изменений системного промпта (добавить, изменить, удалить пункт) ТЫ ОБЯЗАН ВЫЗВАТЬ db_prompt_node_edit.\n" + f" - Пример: 'добавь пункт 3.4 Текст' -> вызови db_prompt_node_edit(action='ADD', section_id=3, item_id=4, content='Текст')\n" + f" - Пример: 'удали пункт 2.9' -> вызови db_prompt_node_edit(action='DELETE', section_id=2, item_id=9)\n" + f" Категорически запрещено подтверждать добавление или удаление простым текстом без вызова db_prompt_node_edit!\n" f"2. Для просмотра системного промпта ВСЕГДА вызывай db_get_system_prompt().\n" - f"3. Для просмотра задач ВСЕГДА вызывай db_get_tasks().\n" - f"4. Не симулируй выполнение функций текстом — сразу вызывай инструмент.\n\n" + f"3. Для просмотра задач ВСЕГДА вызывай db_get_tasks().\n\n" f"[ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ]:\n{dynamic_prompt_text}" ) user_msg_object = {"role": "user", "content": full_user_content} - # --- [SECTION 4: OLLAMA INFERENCE & DISPATCH] --- # ANCHOR[TOOL_ROUTER] + # 4. Вызов модели try: if image_b64: user_msg_object["images"] = [image_b64] @@ -6398,12 +6520,13 @@ def process_chat_message( messages = [{"role": "system", "content": system_prompt_content}] + clean_db_history + [user_msg_object] msg = call_ollama_chat(messages, tools=TOOLS_SCHEMA, is_vision=False) + raw_text_reply = msg.get("content", "") tool_calls = msg.get("tool_calls", []) + tool_calls = inject_tools_if_needed(user_message, raw_text_reply, tool_calls) if tool_calls: logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})") messages.append(msg) - tool_names_called = [] for tool in tool_calls: @@ -6412,9 +6535,7 @@ def process_chat_message( tool_names_called.append(fn_name) logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}") tool_result_content = "" - action_cfg = db_get_tool_action(fn_name) - # ANCHOR[TASK_INTERACTIVE_DISPATCH] if fn_name == "db_get_tasks": raw_tasks = db_get_tasks(user_id) reply_text = "Вот интерактивный список ваших текущих задач:" @@ -6424,13 +6545,53 @@ def process_chat_message( "tasks": raw_tasks } - elif fn_name == "db_preview_prompt_merge": - proposed_text = fn_args.get("prompt_text") or fn_args.get("proposed_prompt") or "" - if isinstance(fn_args, str): - proposed_text = fn_args + elif fn_name == "db_prompt_node_edit": + action = fn_args.get("action", "ADD").upper() + sec_id = int(fn_args.get("section_id", 2)) + itm_id = int(fn_args.get("item_id", 1)) + content = fn_args.get("content", "").strip() - merged_prompt = build_prompt_preview_merge(db_get_active_system_prompt(), user_message, proposed_text) - db_set_session_state(session_id, "PROMPT_PREVIEW", {"draft_text": merged_prompt}) + with get_db_connection() as temp_conn: + temp_cursor = temp_conn.cursor() + temp_cursor.execute(""" + SELECT section_id, item_id, content + FROM system_prompt_nodes + WHERE prompt_name = 'main_agent' AND is_active = 1 + ORDER BY section_id, item_id + """) + existing_nodes = temp_cursor.fetchall() + + nodes_dict = {(sec, itm): txt for sec, itm, txt in existing_nodes} + + if action == "DELETE": + nodes_dict.pop((sec_id, itm_id), None) + else: + nodes_dict[(sec_id, itm_id)] = content + + virtual_lines = [] + curr_sec = None + for (s_id, i_id), txt in sorted(nodes_dict.items()): + if i_id == 0: + if curr_sec is not None: + virtual_lines.append("") + virtual_lines.append(f"{s_id}. {txt}") + curr_sec = s_id + else: + line_str = f" {s_id}.{i_id}. {txt}" + if s_id == sec_id and i_id == itm_id: + line_str = f'{s_id}.{i_id}. {txt}' + virtual_lines.append(line_str) + + merged_prompt = "\n".join([re.sub(r'<[^>]+>', '', l) for l in virtual_lines]) + diff_html = "\n".join(virtual_lines) + + db_set_session_state(session_id, "PROMPT_PREVIEW", { + "draft_text": merged_prompt, + "action": action, + "section_id": sec_id, + "item_id": itm_id, + "content": content + }) with get_db_connection() as conn_fix: conn_fix.cursor().execute(""" @@ -6441,16 +6602,19 @@ def process_chat_message( conn_fix.commit() preview_reply = ( - f"Ваше изменение успешно предпросмотрено. Полный обновленный системный промпт теперь выглядит так:\n\n" - f"{merged_prompt}\n\n" - f"Для применения изменений подтвердите действие («подтверждаю») или отмените («отмена»)." + f"Предпросмотр изменений системного промпта:\n\n" + f"{diff_html}\n\n" + f"Для применения подтвердите действие, отредактируйте или отмените." ) + db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1) - return clean_raw_tool_tags(preview_reply), db_get_chat_history(session_id), { + return preview_reply, db_get_chat_history(session_id), { "type": "PROMPT_PREVIEW", + "raw_draft": merged_prompt, "buttons": [ {"label": "Подтвердить", "value": "подтверждаю", "style": "primary"}, - {"label": "Отменить", "value": "отмена", "style": "danger"} + {"label": "Отменить", "value": "отмена", "style": "danger"}, + {"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"} ] } @@ -6470,9 +6634,6 @@ def process_chat_message( elif fn_name == "db_get_anomalies": tool_result_content = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False) - elif fn_name == "db_get_session_states": - tool_result_content = json.dumps(db_get_session_states(), ensure_ascii=False) - elif fn_name == "db_get_rules": tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False) @@ -6489,50 +6650,41 @@ def process_chat_message( elif fn_name == "db_delete_task": res = db_delete_task(user_id=user_id, task_id=str(fn_args.get("task_id", "")).upper()) - if action_cfg and action_cfg.get("bypass_llm"): - reply_text = action_cfg["success_template"] - if action_cfg.get("follow_up_question"): - reply_text += f"\n\n{action_cfg['follow_up_question']}" - reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=action_cfg.get("is_ephemeral", 1)) - return reply_text, db_get_chat_history(session_id), { - "type": action_cfg.get("action_type"), - "buttons": action_cfg.get("buttons", []) - } tool_result_content = json.dumps(res, ensure_ascii=False) elif fn_name == "db_delete_snapshots": res = db_delete_snapshots(snapshot_id=fn_args.get("snapshot_id"), day_str=fn_args.get("day_str")) - if action_cfg and action_cfg.get("bypass_llm"): - reply_text = action_cfg["success_template"] - if action_cfg.get("follow_up_question"): - reply_text += f"\n\n{action_cfg['follow_up_question']}" - reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=action_cfg.get("is_ephemeral", 1)) - return reply_text, db_get_chat_history(session_id), { - "type": action_cfg.get("action_type"), - "buttons": action_cfg.get("buttons", []) - } tool_result_content = json.dumps(res, ensure_ascii=False) messages.append({"role": "tool", "content": tool_result_content}) - # ANCHOR[SECONDARY_PASS] sec_msg = call_ollama_chat(messages, is_vision=False) raw_content = sec_msg.get("content", "").strip().replace("**", "").replace("*", "") final_content = clean_raw_tool_tags(clean_output(raw_content)) - is_output_ephemeral = 1 if any(name in ["db_get_system_prompt", "db_get_system_prompts", "db_preview_prompt_merge"] for name in tool_names_called) else 0 + is_output_ephemeral = 1 if any(name in ["db_get_system_prompt", "db_get_system_prompts", "db_prompt_node_edit"] for name in tool_names_called) else 0 db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_output_ephemeral) return final_content, db_get_chat_history(session_id), None - # Обычный текстовый диалог + # Обычный текстовый ответ модели (включая вежливый Guard при смене темы) raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "") final_reply = clean_raw_tool_tags(clean_output(raw_str)) or "Запрос обработан." - db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0) - return final_reply, db_get_chat_history(session_id), None + # Если находимся в превью и модель ответила текстом (напомнила о незавершенном изменении), прикрепляем кнопки + action_payload = None + if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": + action_payload = { + "type": "PROMPT_PREVIEW", + "buttons": [ + {"label": "Подтвердить", "value": "подтверждаю", "style": "primary"}, + {"label": "Отменить", "value": "отмена", "style": "danger"}, + {"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"} + ] + } + + db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=(1 if session_state else 0)) + return final_reply, db_get_chat_history(session_id), action_payload except Exception as ex: logger.exception(f"Непредвиденная ошибка: {ex}") @@ -6553,13 +6705,12 @@ from .db.db_snapshots import db_get_snapshots, db_delete_snapshots from .db.db_prompts import ( db_get_active_system_prompt, db_add_system_prompt, + db_apply_prompt_node_action, db_get_tool_action, db_get_rules, db_set_session_state, db_get_session_state, db_clear_session_state, - db_increment_session_idle, - db_get_session_states, db_get_stats, db_get_anomalies, db_get_reference @@ -6754,18 +6905,18 @@ TOOLS_SCHEMA = [ } }, { - "type": "function", - "function": { - "name": "db_delete_snapshots", - "description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.", - "parameters": { - "type": "object", - "properties": { - "snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"}, - "day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"} - } + "type": "function", + "function": { + "name": "db_delete_snapshots", + "description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.", + "parameters": { + "type": "object", + "properties": { + "snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"}, + "day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"} } } + } }, { "type": "function", @@ -6845,76 +6996,36 @@ TOOLS_SCHEMA = [ { "type": "function", "function": { - "name": "db_add_system_prompt", - "description": "Прямое сохранение системного промпта в БД без предварительного просмотра.", - "parameters": { - "type": "object", - "properties": { - "name": {"type": "string", "description": "Имя промпта, по умолчанию main_agent"}, - "prompt_text": {"type": "string", "description": "Полный текст системного промпта"} - }, - "required": ["prompt_text"] - } - } - }, - { - "type": "function", - "function": { - "name": "db_preview_prompt_merge", - "description": "Сформировать предпросмотр изменения системного промпта. Вызывается при любых запросах на добавление ('добавь пункт...'), изменение или удаление пунктов системного промпта ('удали пункт...').", - "parameters": { - "type": "object", - "properties": { - "prompt_text": { - "type": "string", - "description": "Полный текст добавляемого пункта или номер/описание удаляемого пункта." - } - }, - "required": ["prompt_text"] - } - } - }, - { - "type": "function", - "function": { - "name": "db_confirm_prompt_preview", - "description": "Подтвердить и сохранить текущее подготовленное превью в БД. Вызывай этот инструмент, когда пользователь говорит 'подтверждаю', 'да', 'вноси', 'применяй', 'сохраняй' или одобряет превью в любой форме.", - "parameters": {"type": "object", "properties": {}} - } - }, - { - "type": "function", - "function": { - "name": "db_cancel_prompt_preview", - "description": "Отменить текущее превью системного промпта и сбросить изменения. Вызывай, когда пользователь явно отказывается от изменений.", - "parameters": {"type": "object", "properties": {}} - } - }, - # ANCHOR[TOPIC_DRIFT_TOOL_SCHEMA] - { - "type": "function", - "function": { - "name": "db_report_topic_shift", + "name": "db_prompt_node_edit", "description": ( - "Вызывай этот инструмент ВСЕГДА, когда в сессии активно контекстное действие " - "(настройка промпта, срез СКУД, мастер задач), но текущий вопрос пользователя " - "никак не связан с этой активностью и переключает диалог на стороннюю тему." + "Сформировать предпросмотр изменения системного промпта через реляционные узлы. " + "Вызывай при любых запросах на добавление, изменение или удаление пунктов. " + "Раздел и пункт передавай числами (например, для пункта '2.9' -> section_id=2, item_id=9)." ), "parameters": { "type": "object", "properties": { - "reason": { + "action": { "type": "string", - "description": "Краткая семантическая причина, почему тема считается сменившейся" + "enum": ["ADD", "UPDATE", "DELETE"], + "description": "Действие: ADD (добавить пункт), UPDATE (изменить пункт), DELETE (удалить пункт)" }, - "is_explicit_exit": { - "type": "boolean", - "description": "True, если пользователь прямо попросил выйти/завершить работу" + "section_id": { + "type": "integer", + "description": "Номер раздела (например, 2)" + }, + "item_id": { + "type": "integer", + "description": "Номер пункта внутри раздела (например, 9)" + }, + "content": { + "type": "string", + "description": "Текст формулировки пункта (для ADD и UPDATE). Без префикса номера!" } }, - "required": ["reason"] + "required": ["action", "section_id", "item_id"] } - } + } } ] ``` @@ -7008,12 +7119,13 @@ def db_clear_chat_history(session_id: str) -> None: ## File: `./modules/web_api/llm/db/db_prompts.py` ```py """ +=============================================================================== FILE: modules/web_api/llm/db/db_prompts.py -PROJECT: SCUD Orion AI +PROJECT: SCUD Orion AI (Unified Architecture) MODULE: web_api / llm / db -ROLE: Управление системными промптами, правилами базы знаний, - декларативным реестром действий инструментов (tool_action_registry) - и расширенным сессионным состоянием (session_states + idle_turns). +ROLE: Реляционное управление системным промптом (таблица system_prompt_nodes), + базой знаний, реестром действий и сессионными стейтами. +=============================================================================== """ import json import logging @@ -7023,18 +7135,115 @@ from .connection import get_db_connection logger = logging.getLogger("DB_PROMPTS") -def db_get_active_system_prompt() -> str: - """Извлекает актуальный активный системный промпт из SQLite.""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT prompt_text FROM system_prompts WHERE is_active = 1 ORDER BY id DESC LIMIT 1") - row = cursor.fetchone() - conn.close() - return row["prompt_text"] if row else "Ты — ИИ-ассистент SCUD Orion AI." +def init_prompt_nodes_table(): + """Создает реляционную таблицу узлов промпта и заполняет базовыми данными.""" + with get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS system_prompt_nodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + prompt_name TEXT DEFAULT 'main_agent', + section_id INTEGER NOT NULL, + item_id INTEGER NOT NULL, + content TEXT NOT NULL, + is_active INTEGER DEFAULT 1, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(prompt_name, section_id, item_id) + ); + """) + cursor.execute("CREATE INDEX IF NOT EXISTS idx_prompt_nodes ON system_prompt_nodes(prompt_name, section_id, item_id);") + + cursor.execute("SELECT COUNT(*) FROM system_prompt_nodes WHERE prompt_name = 'main_agent'") + if cursor.fetchone()[0] == 0: + seed_nodes = [ + # Раздел 1 + (1, 0, "РОЛЬ И ЗАДАЧИ АССИСТЕНТА"), + (1, 1, "Управление бэклогом задач проекта (через db_get_tasks, db_add_task, db_update_task_status, db_delete_task)."), + (1, 2, "Консультация по правилам и арбитражу кадровых данных/СКУД из базы знаний (через db_get_rules)."), + (1, 3, "Предоставление справки о возможностях и примерах команд (СТРОГО через db_get_reference)."), + (1, 4, "Просмотр аномалий СКУД ⟷ 1С (СТРОГО через db_get_anomalies)."), + (1, 5, "Поддержка диалога с операторами и администраторами системы."), + (1, 7, "Работа с логами, снапшотами и срезами СКУД (через db_get_snapshots)."), + + # Раздел 2 + (2, 0, "ПРАВИЛА ВЫЗОВА ИНСТРУМЕНТОВ И ДАТ"), + (2, 1, "ОБЯЗАТЕЛЬНЫЙ ПРЕВЬЮ-МЕРДЖ: Категорически ЗАПРЕЩЕНО изменять промпт напрямую! При ЛЮБОМ запросе пользователя на изменение системного промпта Ты ОБЯЗАН вызвать инструмент db_prompt_node_edit."), + (2, 2, "БЕЗУСЛОВНОЕ ПОДТВЕРЖДЕНИЕ ПРЕВЬЮ: Инструмент db_confirm_prompt_preview вызывается СТРОГО после того, как пользователь напишет 'подтверждаю', 'да', 'сохраняй'."), + (2, 3, "ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ ЗАДАЧ: При запросе на удаление задач КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО сразу вызывать db_delete_task! Ты ОБЯЗАН сначала спросить пользователя подтверждение."), + (2, 4, "ИСПОЛЬЗОВАНИЕ КАЛЕНДАРЯ: При любых вопросах про даты БЕРИ ТОЧНУЮ ДАТУ ИЗ [СИСТЕМНОГО КАЛЕНДАРЯ СЕРВЕРА] В НАЧАЛЕ КОНТЕКСТА."), + (2, 5, "СТРОГИЙ ВЫЗОВ АНОМАЛИЙ: При запросах аномалий или нарушений Ты ОБЯЗАН вызвать инструмент db_get_anomalies."), + (2, 6, "СТРОГИЙ ВЫЗОВ СПРАВОЧНИКА: При запросах о возможностях, примерах запросов или командах Ты ОБЯЗАН вызывать db_get_reference."), + (2, 7, "Запрещено писать названия функций или код вызова текстом на экран."), + (2, 8, "СТРОГИЙ ВЫЗОВ СНАПШОТОВ: При ЛЮБЫХ запросах про снапшоты, срезы или логи СКУД за дату/день недели Ты ОБЯЗАН вызывать инструмент db_get_snapshots."), + + # Раздел 3 + (3, 0, "ПРАВИЛА СТИЛЯ"), + (3, 1, "Никогда не начинай ответ со склеек или слов-паразитов."), + (3, 2, "Отвечай в чистом текстовом формате (plain text) без спецсимволов или ###, если прямо не попросили."), + (3, 3, "Сохраняй инженерный, лаконичный и профессиональный стиль.") + ] + cursor.executemany(""" + INSERT OR IGNORE INTO system_prompt_nodes (prompt_name, section_id, item_id, content) + VALUES ('main_agent', ?, ?, ?) + """, seed_nodes) + conn.commit() + + +def db_get_active_system_prompt(prompt_name: str = "main_agent") -> str: + """Собирает структурированный текст промпта из реляционной таблицы узлов.""" + init_prompt_nodes_table() + with get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT section_id, item_id, content + FROM system_prompt_nodes + WHERE prompt_name = ? AND is_active = 1 + ORDER BY section_id ASC, item_id ASC + """, (prompt_name,)) + rows = cursor.fetchall() + + if not rows: + return "Ты — ИИ-ассистент SCUD Orion AI." + + lines = [] + current_section = None + + for sec_id, itm_id, content in rows: + if itm_id == 0: + if current_section is not None: + lines.append("") + lines.append(f"{sec_id}. {content}") + current_section = sec_id + else: + lines.append(f" {sec_id}.{itm_id}. {content}") + + return "\n".join(lines) + + +def db_apply_prompt_node_action(action: str, section_id: int, item_id: int, content: str = "", prompt_name: str = "main_agent"): + """Прямое добавление, изменение или удаление узла в БД.""" + init_prompt_nodes_table() + with get_db_connection() as conn: + cursor = conn.cursor() + action_clean = action.upper() + if action_clean in ["ADD", "UPDATE"]: + cursor.execute(""" + INSERT INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active, updated_at) + VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP) + ON CONFLICT(prompt_name, section_id, item_id) DO UPDATE SET + content = excluded.content, + is_active = 1, + updated_at = CURRENT_TIMESTAMP + """, (prompt_name, section_id, item_id, content)) + elif action_clean == "DELETE": + cursor.execute(""" + DELETE FROM system_prompt_nodes + WHERE prompt_name = ? AND section_id = ? AND item_id = ? + """, (prompt_name, section_id, item_id)) + conn.commit() def db_get_tool_action(tool_name: str) -> Optional[Dict[str, Any]]: - """Получение шаблона, кнопок и настроек эфемерности инструмента из SQLite.""" conn = get_db_connection() cursor = conn.cursor() cursor.execute(""" @@ -7053,33 +7262,7 @@ def db_get_tool_action(tool_name: str) -> Optional[Dict[str, Any]]: return None -def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]: - """Сохраняет новую версию системного промпта и активирует её.""" - try: - with get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute("BEGIN IMMEDIATE;") - cursor.execute("SELECT id FROM system_prompts WHERE name = ?", (name,)) - existing = cursor.fetchone() - if existing: - cursor.execute( - "UPDATE system_prompts SET prompt_text = ?, is_active = 1, updated_at = CURRENT_TIMESTAMP WHERE name = ?", - (prompt_text, name) - ) - else: - cursor.execute( - "INSERT INTO system_prompts (name, prompt_text, is_active) VALUES (?, ?, 1)", - (name, prompt_text) - ) - conn.commit() - return {"status": "success", "message": "Системный промпт успешно обновлен"} - except Exception as e: - logger.error(f"Ошибка при сохранении промпта в БД: {e}") - return {"status": "error", "error": str(e)} - - def db_get_rules() -> List[Dict[str, Any]]: - """Извлекает список глобальных правил компании из ai_knowledge_base.""" conn = get_db_connection() cursor = conn.cursor() cursor.execute("SELECT id, rule_text FROM ai_knowledge_base ORDER BY id ASC") @@ -7088,23 +7271,11 @@ def db_get_rules() -> List[Dict[str, Any]]: return [dict(r) for r in rows] -# ============================================================================= -# РАБОТА С СЕССИОННЫМИ СОСТОЯНИЯМИ, ЧЕРНОВИКАМИ И СЧЕТЧИКОМ ОТВЛЕЧЕНИЙ (IDLE_TURNS) -# ============================================================================= - def db_set_session_state(session_id: str, state_type: str, data: Any) -> None: - """ - Сохраняет состояние сессии в SQLite. - Если передан dict/list — автоматически сериализует его в JSON-строку. - """ + """Сохраняет состояние сессии в SQLite.""" conn = get_db_connection() cursor = conn.cursor() - - if isinstance(data, (dict, list)): - payload_str = json.dumps(data, ensure_ascii=False) - else: - payload_str = str(data) if data is not None else "" - + payload_str = json.dumps(data, ensure_ascii=False) if isinstance(data, (dict, list)) else (str(data) if data is not None else "") cursor.execute(""" INSERT INTO session_states (session_id, state_type, pending_data, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) @@ -7118,63 +7289,23 @@ def db_set_session_state(session_id: str, state_type: str, data: Any) -> None: def db_get_session_state(session_id: str) -> Optional[Dict[str, Any]]: - """ - Извлекает состояние сессии из SQLite. - Если в pending_data лежит валидный JSON-объект — парсит его в data_json. - """ conn = get_db_connection() cursor = conn.cursor() - cursor.execute( - "SELECT session_id, state_type, pending_data, updated_at FROM session_states WHERE session_id = ?", - (session_id,) - ) + cursor.execute("SELECT session_id, state_type, pending_data, updated_at FROM session_states WHERE session_id = ?", (session_id,)) row = cursor.fetchone() conn.close() - if not row: return None - res = dict(row) raw_data = res.get("pending_data") or "" - try: - if raw_data.strip().startswith("{") or raw_data.strip().startswith("["): - res["data_json"] = json.loads(raw_data) - else: - res["data_json"] = None + res["data_json"] = json.loads(raw_data) if raw_data.strip().startswith(("{", "[")) else None except Exception: res["data_json"] = None - return res -def db_increment_session_idle(session_id: str) -> int: - """ - Инкрементирует счетчик idle_turns для активного черновика сессии. - Возвращает обновленное значение счетчика отвлечений. - """ - state = db_get_session_state(session_id) - if not state: - return 0 - - data_meta = state.get("data_json") - if isinstance(data_meta, dict): - current_turns = data_meta.get("idle_turns", 0) + 1 - data_meta["idle_turns"] = current_turns - db_set_session_state(session_id, state["state_type"], data_meta) - return current_turns - else: - # Если ранее данные были сохранены обычной строкой - new_meta = { - "draft_text": state.get("pending_data", ""), - "idle_turns": 1 - } - db_set_session_state(session_id, state["state_type"], new_meta) - return 1 - - def db_clear_session_state(session_id: str) -> None: - """Сбрасывает и удаляет активное состояние сессии.""" conn = get_db_connection() cursor = conn.cursor() cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,)) @@ -7182,28 +7313,10 @@ def db_clear_session_state(session_id: str) -> None: conn.close() -def db_get_session_states() -> Dict[str, Any]: - """Возвращает реестр всех активных сессий.""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT session_id, state_type, updated_at FROM session_states") - rows = cursor.fetchall() - conn.close() - return {"status": "success", "active_sessions": [dict(r) for r in rows]} - - -# ============================================================================= -# СЛУЖЕБНАЯ ДИАГНОСТИКА И СТАТИСТИКА -# ============================================================================= - def db_get_stats() -> Dict[str, Any]: - """Возвращает статистику по количеству записей в таблицах.""" conn = get_db_connection() cursor = conn.cursor() - tables = [ - 'scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', - 'ai_knowledge_base', 'system_prompts', 'session_states', 'tasks' - ] + tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'system_prompt_nodes', 'session_states', 'tasks'] stats = {} for t in tables: try: @@ -7216,7 +7329,6 @@ def db_get_stats() -> Dict[str, Any]: def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[str, Any]: - """Извлекает журнал зафиксированных аномалий.""" conn = get_db_connection() cursor = conn.cursor() query = "SELECT anomaly_date, fio, anomaly_type, details FROM anomalies_history" @@ -7233,7 +7345,6 @@ def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[s def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]: - """Извлекает справочные команды и примеры подсказок.""" conn = get_db_connection() cursor = conn.cursor() query = "SELECT category, title, example_prompt, description FROM system_reference" @@ -7246,6 +7357,53 @@ def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]: rows = cursor.fetchall() conn.close() return {"status": "success", "count": len(rows), "reference_items": [dict(r) for r in rows]} + +def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]: + """Мост обратной совместимости: парсит текст промпта и сохраняет по узлам в БД.""" + try: + init_prompt_nodes_table() + with get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM system_prompt_nodes WHERE prompt_name = ?", (name,)) + + current_sec = 1 + current_itm = 0 + + for line in prompt_text.splitlines(): + stripped = line.strip() + if not stripped: + continue + + # Проверяем заголовок раздела (например, "1. РОЛЬ И ЗАДАЧИ...") + parts = stripped.split(".", 1) + if len(parts) == 2 and parts[0].strip().isdigit(): + sec_num = int(parts[0].strip()) + rest = parts[1].strip() + + # Проверяем, это подпункт (например, "1.1. Текст") или заголовок раздела + sub_parts = rest.split(".", 1) + if len(sub_parts) == 2 and sub_parts[0].strip().isdigit(): + current_sec = sec_num + current_itm = int(sub_parts[0].strip()) + content = sub_parts[1].strip() + else: + current_sec = sec_num + current_itm = 0 + content = rest + else: + current_itm += 1 + content = stripped + + cursor.execute(""" + INSERT OR REPLACE INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active) + VALUES (?, ?, ?, ?, 1) + """, (name, current_sec, current_itm, content)) + + conn.commit() + return {"status": "success", "message": "Системный промпт успешно сохранен по узлам"} + except Exception as e: + logger.error(f"Ошибка при разборе промпта в узлы: {e}") + return {"status": "error", "error": str(e)} ``` ## File: `./modules/web_api/llm/db/db_snapshots.py` @@ -7437,8 +7595,10 @@ def get_dynamic_calendar_context() -> str: """ =============================================================================== FILE: modules/web_api/llm/core/fast_path.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / llm / core ROLE: Детерминированный перехват подтверждения, отмены и мгновенной очистки - эфемерного контекста по кнопкам завершения. + эфемерного контекста без повторного вывода кнопок-вилок. =============================================================================== """ @@ -7448,8 +7608,6 @@ from typing import Dict, Any, Tuple, Optional from llm.db_tools import ( db_add_system_prompt, - db_get_tool_action, - db_set_session_state, db_clear_session_state, db_purge_ephemeral_messages, db_save_chat_message, @@ -7458,8 +7616,6 @@ from llm.db_tools import ( logger = logging.getLogger("FAST_PATH") -TERMINATE_ACTIONS = {"нет, спасибо", "нет, закончить настройку", "закончить настройку", "завершить", "готово", "нет"} - # ANCHOR[FAST_PATH_ROUTER] def handle_fast_path_intercept( @@ -7469,65 +7625,40 @@ def handle_fast_path_intercept( session_state: Optional[Dict[str, Any]] ) -> Optional[Tuple[str, list, Optional[Dict[str, Any]]]]: """ - 1. Перехват кнопок завершения -> моментальное удаление эфемерных записей. - 2. Перехват подтверждения/отмены черновика промпта. + 1. Подтвердить -> запись в БД + db_purge_ephemeral_messages + инфо-текст. + 2. Отменить -> сброс стейта + db_purge_ephemeral_messages + инфо-текст. """ + if not session_state or session_state.get("state_type") != "PROMPT_PREVIEW": + return None + user_msg_clean = user_message.lower().strip(" .!?:;") - # 1. Завершение работы с инструментом -> мгновенная очистка эфемерных сообщений - if user_msg_clean in TERMINATE_ACTIONS: + draft_text = "" + if session_state.get("data_json") and isinstance(session_state["data_json"], dict): + draft_text = session_state["data_json"].get("draft_text", "") + else: + draft_text = session_state.get("pending_data", "") + + # 1. ПОДТВЕРДИТЬ ИЗМЕНЕНИЯ + if user_msg_clean in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]: + db_add_system_prompt("main_agent", draft_text) db_clear_session_state(session_id) deleted_count = db_purge_ephemeral_messages(session_id) - logger.info(f"Сессия закрыта по кнопке/команде '{user_msg_clean}'. Удалено эфемерных сообщений: {deleted_count}") - - reply_text = "Работа с инструментом завершена. Контекст диалога чист. Чем могу помочь?" + logger.info(f"Системный промпт сохранен в БД. Удалено эфемерных сообщений: {deleted_count}") + + reply_text = "Системный промпт успешно сохранен и применен в базе данных." db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0) return reply_text, db_get_chat_history(session_id), None - # 2. Подтверждение / отмена превью системного промпта - if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": - draft_text = "" - if session_state.get("data_json") and isinstance(session_state["data_json"], dict): - draft_text = session_state["data_json"].get("draft_text", "") - else: - draft_text = session_state.get("pending_data", "") + # 2. ОТМЕНИТЬ ИЗМЕНЕНИЯ + elif user_msg_clean in ["отмена", "отменить", "отклонить", "назад", "стоп"]: + db_clear_session_state(session_id) + deleted_count = db_purge_ephemeral_messages(session_id) + logger.info(f"Изменения промпта отменены. Удалено эфемерных сообщений: {deleted_count}") - if user_msg_clean in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]: - db_add_system_prompt("main_agent", draft_text) - db_set_session_state(session_id, "PROMPT_FOLLOWUP", {}) - - action_cfg = db_get_tool_action("db_confirm_prompt_preview") - reply_text = action_cfg["success_template"] if action_cfg else "Системный промпт успешно сохранен и применен в базе данных." - if action_cfg and action_cfg.get("follow_up_question"): - reply_text += f"\n\n{action_cfg['follow_up_question']}" - - reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() - - is_eph = action_cfg.get("is_ephemeral", 1) if action_cfg else 1 - db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1) - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph) - return reply_text, db_get_chat_history(session_id), { - "type": action_cfg.get("action_type") if action_cfg else "FOLLOW_UP_ACTION", - "buttons": action_cfg.get("buttons", []) if action_cfg else [] - } - - elif user_msg_clean in ["отмена", "отменить", "отклонить", "назад"]: - db_set_session_state(session_id, "PROMPT_FOLLOWUP", {}) - - action_cfg = db_get_tool_action("db_cancel_prompt_preview") - reply_text = action_cfg["success_template"] if action_cfg else "Изменения системного промпта отменены." - if action_cfg and action_cfg.get("follow_up_question"): - reply_text += f"\n\n{action_cfg['follow_up_question']}" - - reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() - - is_eph = action_cfg.get("is_ephemeral", 1) if action_cfg else 1 - db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1) - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph) - return reply_text, db_get_chat_history(session_id), { - "type": action_cfg.get("action_type") if action_cfg else "FOLLOW_UP_ACTION", - "buttons": action_cfg.get("buttons", []) if action_cfg else [] - } + reply_text = "Изменения системного промпта отменены. Контекст диалога чист." + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0) + return reply_text, db_get_chat_history(session_id), None return None ``` @@ -7602,15 +7733,15 @@ def call_ollama_chat( """ =============================================================================== FILE: modules/web_api/llm/core/prompt_merger.py -ROLE: Алгоритмы парсинга, предпросмотра и детерминированного слияния правок - системного промпта (добавление, удаление и замена пунктов). +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / llm / core +ROLE: Алгоритмы парсинга, предпросмотра, слияния и визуального выделения правок. =============================================================================== """ # ANCHOR[PROMPT_MERGER_IMPORTS] import re import logging -from typing import Tuple logger = logging.getLogger("PROMPT_MERGER") @@ -7618,9 +7749,7 @@ logger = logging.getLogger("PROMPT_MERGER") # ANCHOR[PROMPT_MERGE_ENGINE] def build_prompt_preview_merge(current_prompt: str, user_message: str, proposed_text: str) -> str: """ - Вычисляет результирующий текст промпта на основе намерения пользователя: - 1. Удаление пункта по номеру. - 2. Добавление/изменение пункта в соответствующую секцию. + Вычисляет результирующий чистый текст промпта для записи в БД. """ user_msg_lower = user_message.lower() @@ -7658,15 +7787,36 @@ def build_prompt_preview_merge(current_prompt: str, user_message: str, proposed_ return "\n".join(new_lines) return proposed_text or current_prompt + + +def highlight_prompt_diff(old_prompt: str, new_prompt: str) -> str: + """ + Генерирует текст с подсветкой добавленных/измененных строк HTML-классами Tailwind. + """ + old_lines_set = {line.strip() for line in old_prompt.splitlines() if line.strip()} + diff_lines = [] + + for line in new_prompt.splitlines(): + if line.strip() and line.strip() not in old_lines_set: + # Выделяем новую или измененную строку красным цветом + diff_lines.append(f'{line}') + else: + diff_lines.append(line) + + return "\n".join(diff_lines) ``` ## File: `./modules/web_api/llm/core/tool_injector.py` ```py """ +=============================================================================== FILE: modules/web_api/llm/core/tool_injector.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / llm / core +ROLE: Санитарная очистка сырых артефактов и тегов из ответов LLM. +=============================================================================== """ import re -import json import logging from typing import List, Dict, Any @@ -7674,6 +7824,7 @@ logger = logging.getLogger("TOOL_INJECTOR") def clean_raw_tool_tags(text: str) -> str: + """Удаляет сырые теги вызова инструментов, если модель случайно вывела их в текст.""" if not text: return "" text = re.sub(r'\{"name":\s*"db_[^}]+\}\s*()?', '', text) @@ -7684,10 +7835,10 @@ def clean_raw_tool_tags(text: str) -> str: def clean_output(text: str) -> str: + """Удаляет слова-паразиты и склейки в начале ответа.""" if not text: return text - # Фильтрация паразитных артефактов токенизатора Qwen artifacts = [ "почемучка", "почемучка,", "почемучка!", "почемучка?", "почемучто", "почто", "почему что", "почему-то", @@ -7703,17 +7854,9 @@ def clean_output(text: str) -> str: def inject_tools_if_needed(user_message: str, raw_text_content: str, tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - if tool_calls: - return tool_calls - if '{"name":' in raw_text_content or '' in raw_text_content: - try: - match = re.search(r'\{"name":\s*"([^"]+)",\s*"(?:params|arguments|properties)":\s*(\{.*?\})\}', raw_text_content) - if match: - fn_name = match.group(1) - fn_args = json.loads(match.group(2)) - return [{"function": {"name": fn_name, "arguments": fn_args}}] - except Exception as parse_err: - logger.debug(f"Ошибка парсинга сырого tool call: {parse_err}") + """ + Проходной фильтр: все решения о вызове инструментов принимает исключительно LLM. + """ return tool_calls ``` diff --git a/scripts/db_cli.py b/scripts/db_cli.py index f3cda0e..e24cec3 100644 --- a/scripts/db_cli.py +++ b/scripts/db_cli.py @@ -51,12 +51,12 @@ def print_stats(): print("=" * 60) with get_connection() as conn: cursor = conn.cursor() - tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'chat_messages', 'session_states'] + tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'chat_messages', 'session_states', 'system_prompt_nodes', 'tasks'] for t in tables: try: cursor.execute(f"SELECT COUNT(*) FROM {t}") cnt = cursor.fetchone()[0] - print(f" • Таблица [{t:<18}]: {cnt:>6} записей") + print(f" • Таблица [{t:<20}]: {cnt:>6} записей") except Exception: pass print("=" * 60 + "\n") @@ -151,7 +151,7 @@ def inspect_scud(snapshot_id=None, date_str=None, export_xlsx=None): df = load_scud_from_db_by_snapshot(target_date, snapshot_param=snapshot_id) if df.empty: - print(f"Записи СКУД не найдены.") + print("Записи СКУД не найдены.") print("=" * 90 + "\n") return @@ -318,7 +318,6 @@ def print_chat_messages(session_id=None, limit=50): if not rows: print("Таблица chat_messages пуста.") else: - # Если выводили все без конкретной сессии в порядке DESC, развернем для читаемости if not session_id: rows = list(reversed(rows)) @@ -327,13 +326,53 @@ def print_chat_messages(session_id=None, limit=50): eph_marker = " [ЭФЕМЕРНОЕ]" if ephemeral else "" print(f"[{msg_id}] {created} | Сессия: {sess} | Роль: {role.upper()}{eph_marker}") print("-" * 105) - # Выводим контент, ограничивая длинные сообщения при необходимости content_preview = content if content else "" print(f"{content_preview}") print("=" * 105) print("\n") +def purge_chat_context(session_id=None, purge_all=False): + """ + Очистка контекста сообщений: + - По умолчанию: удаляет эфемерные сообщения, осиротевшие превью и сбрасывает стейты сессий. + - purge_all=True (--all): полностью очищает всю таблицу chat_messages и сбрасывает сессии. + """ + with get_connection() as conn: + cursor = conn.cursor() + + if purge_all: + if session_id: + cursor.execute("DELETE FROM chat_messages WHERE session_id = ?", (session_id,)) + cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,)) + else: + cursor.execute("DELETE FROM chat_messages") + cursor.execute("DELETE FROM session_states") + deleted_msgs = cursor.rowcount + conn.commit() + print(f"\n[✓] Полная очистка истории выполнена! Удалено сообщений: {deleted_msgs}\n") + return + + query = """ + DELETE FROM chat_messages + WHERE is_ephemeral = 1 + OR content LIKE '%Предпросмотр изменений%' + OR content LIKE '%Удален пункт:%' + OR content LIKE '%добавлен пункт:%' + """ + if session_id: + cursor.execute(query + " AND session_id = ?", (session_id,)) + cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,)) + else: + cursor.execute(query) + cursor.execute("DELETE FROM session_states") + + deleted_msgs = cursor.rowcount + conn.commit() + + print(f"\n[✓] Умная зачистка контекста выполнена! Удалено сообщений: {deleted_msgs}\n") + + HELP_TEXT = """ CLI-утилита инспекции и управления SQLite базой данных СКУД (scud_orion_ai.db) @@ -348,6 +387,7 @@ CLI-утилита инспекции и управления SQLite базой tools -- Посмотреть реестр действий инструментов и кнопок (tool_action_registry) sessions -- Посмотреть активные сессии и превью (session_states) context [session_id] [--limit N] -- Посмотреть таблицу контекста сообщений чата (chat_messages) + context purge [session_id] [--all] -- Очистить контекст (умная зачистка или полная с флагом --all) dump [output.xlsx] -- Полный дамп всех таблиц БД в многостраничный Excel snapshot del [ID] или [--day ДД.ММ.ГГГГ] -- Удаление снапшота по ID или всех за выбранный день @@ -360,6 +400,9 @@ CLI-утилита инспекции и управления SQLite базой python scripts/db_cli.py tools python scripts/db_cli.py sessions python scripts/db_cli.py context web_session_main --limit 20 + python scripts/db_cli.py context purge + python scripts/db_cli.py context purge --all + python scripts/db_cli.py context purge web_session_main --all python scripts/db_cli.py snapshot del Y20260805-007 python scripts/db_cli.py dump my_dump.xlsx """ @@ -376,12 +419,13 @@ def main(): add_help=False ) parser.add_argument('command', nargs='?', default=None, choices=['stats', 'snapshots', 'scud', 'absences', 'anomalies', 'rules', 'prompts', 'sessions', 'dump', 'snapshot', 'tools', 'context'], help="Основная команда") - parser.add_argument('action', nargs='?', default=None, help="Дополнительное действие (например, 'del') или session_id для контекста") - parser.add_argument('param', nargs='?', default=None, help="Параметр (дата, ID снапшота или имя файла)") + parser.add_argument('action', nargs='?', default=None, help="Дополнительное действие (например, 'del', 'purge') или session_id для контекста") + parser.add_argument('param', nargs='?', default=None, help="Параметр (дата, ID снапшота, session_id, '--all' или имя файла)") parser.add_argument('--snapshot', type=str, default=None, help="ID конкретного снапшота для инспекции") parser.add_argument('--export-xlsx', type=str, default=None, help="Экспорт среза СКУД в Excel-файл") parser.add_argument('--day', type=str, default=None, help="Удалить снапшоты за конкретный день (ДД.ММ.ГГГГ)") parser.add_argument('--limit', type=int, default=50, help="Лимит выводимых сообщений чата (для команды context)") + parser.add_argument('--all', action='store_true', help="Полная очистка всех сообщений (для команды context purge)") if len(sys.argv) == 1: print_stats() @@ -410,8 +454,13 @@ def main(): elif args.command == 'tools': print_tool_actions() elif args.command == 'context': - sess_id = args.action if args.action else None - print_chat_messages(session_id=sess_id, limit=args.limit) + if args.action in ['purge', 'clear']: + is_all = args.all or (args.param == '--all') + sess_id = None if (args.param == '--all' or not args.param) else args.param + purge_chat_context(session_id=sess_id, purge_all=is_all) + else: + sess_id = args.action if args.action else None + print_chat_messages(session_id=sess_id, limit=args.limit) elif args.command == 'dump': filename = args.param if args.param else "db_dump_full.xlsx" dump_all_to_excel(filename) @@ -428,7 +477,7 @@ def main(): print(f"\n[❌] Ошибка: Неизвестное действие '{args.action}' для команды snapshot.") print("Используйте: python scripts/db_cli.py snapshot del [ID или --day 'ДД.ММ.ГГГГ']\n") else: - print(f"\n[❌] Ошибка: Неизвестная команда.") + print("\n[❌] Ошибка: Неизвестная команда.") print(HELP_TEXT) if __name__ == "__main__": diff --git a/scripts/diagnostics/make_code_snapshot.py b/scripts/diagnostics/make_code_snapshot.py index 0c560aa..7d88141 100644 --- a/scripts/diagnostics/make_code_snapshot.py +++ b/scripts/diagnostics/make_code_snapshot.py @@ -2,15 +2,29 @@ import os OUTPUT_SNAPSHOT = "project_code_snapshot.md" -# Расширения файлов для включения в снимок -ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini', '.js', '.html', '.css', '.sql'} -EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'} -EXCLUDE_FILES = {OUTPUT_SNAPSHOT, 'scud_orion_ai_v2.tar.gz', 'context_memory.db'} +# Расширения файлов для включения в снимок (только исходный код) +ALLOWED_EXTENSIONS = {'.py', '.json', '.sh', '.ini', '.js', '.html', '.css', '.sql'} -print(f"🔄 Сборка полного контекстного слепка проекта в {OUTPUT_SNAPSHOT}...") +# Исключаемые каталоги (убираем docs, кэши, архивы и окружения) +EXCLUDE_DIRS = { + '.git', '__pycache__', 'venv', '.venv', 'output', 'logs', + 'extracted_project', 'docs', 'data' +} + +# Исключаемые файлы +EXCLUDE_FILES = { + OUTPUT_SNAPSHOT, + 'api_code_snapshot.md', + 'project_code_snapshot.md', + 'scud_orion_ai_v2.tar.gz', + 'scud_context_api.tar.gz', + 'context_memory.db' +} + +print(f"🔄 Сборка оптимизированного слепка кода в {OUTPUT_SNAPSHOT}...") with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out: - out.write("# 📦 ПОЛНЫЙ ИСХОДНЫЙ КОД И КОНФИГУРАЦИЯ ПРОЕКТА scud_orion_ai_v2\n\n") + out.write("# 📦 ОПТИМИЗИРОВАННЫЙ СЛЕПОК ИСХОДНОГО КОДА scud_orion_ai\n\n") for root, dirs, files in os.walk('.'): dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS] @@ -20,7 +34,7 @@ with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out: if ext in ALLOWED_EXTENSIONS and file not in EXCLUDE_FILES: filepath = os.path.join(root, file) out.write(f"## File: `{filepath}`\n") - out.write("```" + (ext.replace('.', '') if ext != '.md' else '') + "\n") + out.write("```" + ext.replace('.', '') + "\n") try: with open(filepath, 'r', encoding='utf-8', errors='replace') as f: out.write(f.read()) @@ -28,4 +42,5 @@ with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out: out.write(f"// Ошибка чтения файла: {e}\n") out.write("\n```\n\n") -print(f"✓ Успешно создан слепок проекта: {OUTPUT_SNAPSHOT} ({os.path.getsize(OUTPUT_SNAPSHOT):,} bytes)") \ No newline at end of file +size_kb = os.path.getsize(OUTPUT_SNAPSHOT) / 1024 +print(f"✓ Слепок успешно создан: {OUTPUT_SNAPSHOT} ({size_kb:.1f} KB)") \ No newline at end of file