From 982b3a231774a1b116be9a96b63f288e3538bf4c Mon Sep 17 00:00:00 2001 From: jillion <439912596@qq.com> Date: Fri, 18 Sep 2026 13:38:02 +0800 Subject: [PATCH] =?UTF-8?q?=E8=90=BD=E5=9C=B0=E9=99=86=E8=BF=90=E7=BE=A4?= =?UTF-8?q?=E5=86=85=E5=8D=8F=E5=90=8C=EF=BC=9A=E6=8C=89=E5=A1=AB=E5=86=99?= =?UTF-8?q?=E6=A0=B7=E4=BE=8B=E6=8B=89=E7=BE=A4=E5=B9=B6=E5=8F=AA=E5=88=97?= =?UTF-8?q?=E6=9C=AC=E5=8D=95=E5=8D=8F=E5=90=8C=E9=A1=B9=EF=BC=8C=E8=BF=9B?= =?UTF-8?q?=E7=BE=A4=E6=8A=8A=E7=A7=81=E8=81=8A=E5=B7=B2=E6=8A=BD=E5=88=B0?= =?UTF-8?q?=E7=9A=84=E5=AD=97=E6=AE=B5=E5=B8=A6=E4=B8=8A=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../agent/assets/land_sample_pairs.png | Bin 0 -> 86465 bytes inquiry-agent/agent/channel/outbox/sender.py | 85 ++++- inquiry-agent/agent/llm/extract_text.py | 109 ++++++- inquiry-agent/agent/policy/air_group_ops.py | 18 ++ inquiry-agent/agent/policy/inquiry_copy.py | 77 +++-- inquiry-agent/agent/policy/land_text_flow.py | 64 +++- inquiry-agent/agent/policy/quote_overlay.py | 4 + inquiry-agent/agent/policy/sea_group_ops.py | 105 ++++-- inquiry-agent/agent/policy/sea_text_flow.py | 22 +- inquiry-agent/agent/schema/field_validate.py | 171 +++++++++- inquiry-agent/agent/schema/land_options.py | 295 ++++++++--------- inquiry-agent/agent/schema/tms_land_query.py | 22 +- inquiry-agent/tests/test_air_group_collab.py | 31 ++ .../tests/test_land_collab_fields.py | 102 ++++++ inquiry-agent/tests/test_land_copy.py | 78 ++++- .../tests/test_land_field_validate.py | 6 +- inquiry-agent/tests/test_land_group_collab.py | 305 ++++++++++++++++++ inquiry-agent/tests/test_land_options.py | 132 ++++++-- inquiry-agent/tests/test_land_text_inquiry.py | 53 ++- inquiry-agent/tests/test_sea_text_inquiry.py | 33 ++ inquiry-agent/tests/test_tms_land_query.py | 6 +- .../inquiry/tms/TmsLandEnumMapper.java | 2 + .../inquiry/tms/TmsLandSegmentMapper.java | 32 +- .../inquiry/tms/TmsLandEnumMapperTest.java | 6 + .../inquiry/tms/TmsLandSegmentMapperTest.java | 8 +- 25 files changed, 1451 insertions(+), 315 deletions(-) create mode 100644 inquiry-agent/agent/assets/land_sample_pairs.png create mode 100644 inquiry-agent/tests/test_land_collab_fields.py create mode 100644 inquiry-agent/tests/test_land_group_collab.py diff --git a/inquiry-agent/agent/assets/land_sample_pairs.png b/inquiry-agent/agent/assets/land_sample_pairs.png new file mode 100644 index 0000000000000000000000000000000000000000..122e4ff78b4b4c049d2c619e4bcebf0e6b582d9a GIT binary patch literal 86465 zcmeFZcU03`5I=|&6e&+cM4E_#bftF`P^3$j-fQT+N2Q4fC`b!UdhdiDKza+khTd!F zA$0cg-rKY1xBK>--Th;C&z_AQ0^v(?bMMT2KJ%HG1bvW~B)I$dE)EV3fz*2mB^;bv zPjGN<&f;AMkC?A|zQehOgCiyJM#T-aIc4HD(cTy^SG@4j$k)Ww^nGk>>??3DOI}d~ z?o3UW=?S>Hi@klaHk_#(l=!BmR_b~%-g|f~%52nt8zuD!6M4(1W7cmYWAveEVy)otv?&!I*wmrX&p#paxr z(vXml`#Q6oaIL{*mi2);6Xbg2gWI<|pw3}FP2OJTd&@nsysn|ORgQ~Y5&ND#?`37J zLdBN2X*k^~6NIZHlpp_Xue0?jAJ*32{GqL%4Q63w-O>~OxjgJGa!NqKiJA0g7W+D9 zl-U#aN@R|MDbjM@XfAFQY7K*1Y0fk@3iT7@46n2a+kknE>6&= z9!P-ze}5So*KN^oPRWt21k@@^xi|JRculFnBrmVbZcTj~8&r-)lSIWac=o=cm&ms8 z7JNN~Pu$==9GpAbzz=t}e=+(>2BqAB%b50PIUKLcp~H7}zK-ulL!uwBYi5}Y-j=f7 zUq*YPXk;ER$LHu+_N6$sPCie+451YDt^eyE7r!7km3?etYnFSQrIO=8^ccNAuAs~T zx!}0`^cpEiLb4DWA=zPElE&kPZlywpJr5?Ge{uuo_Zt#>oQC??pyJ}#YzrmF)Rfd= zOSntzudeV~jFqKf_z(*nowy@sDNRY!Uq62q7u$=juS`sg_r!4J1dr;=Nf{dtCDah< zR*sc+A#gz>7Dv%1%`~&JYP* zS7nN_9xuB9`{7UA68sRx@BZsSvMSSX)-I1B!p+CNp`5;TI95~t4u4sK^n>1-B z9__L`4TIk!F2ngAL?BmbR8LYOqc3hI%|DyxBY7;X(tq$V!>E4`9voE|nc-@y3%6M~ z6vn+zp0$YznMmY@J%gWbRBVAG(uBFluedE8_$1ttPptZVu6TDWoUCx#JR{`$y}_EA zUF`LLET^KM{J9``$H7ydy@nB%Sk7nMt}BVYf>_ESulQFM#`~?93vbE2p>Y`$WA*F0 zNmy9nbnT$5Sx?M#!zX!NfG!}|U(kv*HSR@c!B+Er&;IycB_loPd*V{4nm<5vc{mY$ z;UluSLi3?kj0#<)!C@l1A7v){nBQoieP-uVy*u^|CoGy+%;5KruKEmfUwLFijs%x5sZS$AF8EzU z8{v}Gzb=7&$;-PDW2LSe&P`GNRzhOE^beLs^xUTY6?9trub+x{y3Wt8Z;802=_r+} zyc%rw(5NVd_lxT5Rrv_E=y1GxwLE#_pDj`^E_+EcSt$7}7(M3rq4(4I)_I#=>53%Q z>O=OB*w?JpRT#lUYg-${6mC0J1_@LfD2qrHPrmLh2#5T%!w(EiSRcNOJ6wrqBakuG zW(%2crKxqt(q>nw7tToY>r;>b5B_@_b-YARA=7;qv_9h`R_bJC7V`~1BT#Fa)sWe( zLlRurbn(gurBblh)S}*r^ABa^G$Z2^RpWnNZqtp6B|C&ofkyrC*qFI~lFi*4zBCshTCQ7R-@i#)ZcX24;~M0M zwZaVW+Z6POef7o-!%%Ukamq+f;rz4fSUabp8HBs)w_t9o@fOi1FI!sX=jTOEk0H^O z)oG{Mr}akIKdU!;bAelhlWy2dR#&)f8B5}N(n|C@z^`AgPpjnaeJppMRkUoQcapzU zM(e_HKQv7ZyH8Y^g1PR}FLs>n#Ok**%+CUiyzx)e)@y8dUC_JHdHc45clqI5M4@#0 z0Gqx2Mb$OW!E#|KWVOqJ%TxxH8cTevsBHla#+j^e@_hzbMZO>wa(Q;!ITw=QEea(m znz{ob#TC=J`$`le=aF&3X)7a?^q$_*Jc+MQ$+wWR4r z%0|FQNvRN~O={@yHU+(A=13(@Otj`{_RE)c4o-`}9M3j7_;2FiqzUU^cC$5;gi;~w z#$gXezlDarQaLN4Smk##j_g{3#pek_xTW`!t;R}np)&t+h03H#H8#!O(Bmrc`U9tiua$D76uuCP7V-UT4) z40CxTSX=LM$o4~&DD&>$fvlk(8HQez&BWiOp)7*8hK7cPC2=(gWcKtnMt!~@T$)_ayRDm6 z)^#^;K?()EeyFQBIiVj%Mz})+tV_(0UhA7JJ!mc2q#B8uT8UyYRpv-h@D{U!r2HM8 zhf#_~3_ZNNdz0cHlG>pqWZOwJshO&#V>?w*Z*67)*aM;(j$I$|8S*2KtJs#S{9YAnu@yhgr}}S*{V8&M zl*kWV>OSrwAFScE)!t4jnGm?44Z+=PytP+w0jV~Hf0GX-thXvGG3ofFLb1K6!HHc* z6==1MI8=G9AI;IXwnBKU1~oDqS{wob0^%e7HBlizQ_`7ve`IKJ4_XskBy8)?)Sdn| z=wcIf2?SVP<~*fDDHf(bYXW!`tz5fti9H?eV+soPQzOLTb!SVJ<@kTX`q5NNbF+xo8CU;SUWB3k z6IWNqezRh|OUIOyI)X9Hg!rnOD!#FaTQ_|e)iWffC;7wgj$OKzmye)mkVskPU#u-b zcmIh=r`1lF)&u@v8ew;88k(eT!?<$vbeVO9OVGC2CBUML!wsfU3pPLV(Htodx_nF< zLQm-Rp@OpN7qwOL2?)MQTkOnX*gOq!rT&^FIy?Yl2^MT${V&|t8H>4W|5<>JhusbP zI&@(k{zO0S5d~R$^l8P^<*xGbJM;6K{-583^;q+({}aZ2AXVY$7}R8U8QAFF#Cos2 z&84t&UBGT=_}HSGku4f{b1O8&WMo)m;mb?YhWr$Nd@k#c499ZgC{6Wz6_M;JAD?Tk zG(rPxj9NJ{*%skV9#z#DgO}=g<3;G%{s`1_%*IGxCb!7HrjcZommfdKf8ujE%Erb0 zDtl8S!zF}?v@4qD)vGN|x9%Ej(*1&5rQDQMBU#z+OC`3tWoo&@iv9hjQn>PTbWPg) zB?$tR7_+mKsnf94Uulj`LwA}OZJq%TU?xM#T5cs9WqM#}vUNDzCyTzXP1QqJ3U%v+ zO_r}G7!B8W;wH2lS{?f;ll|jw6%m}CYK;1EF^|+)^s2!^B89um>nm#Q4xgcy0Cnrt zsdU&}tcoyWlaOq4vY1gCyislpqE%CyJiNS|NxjZ{lOF$d-c-AQfj?OF06osrB@ zoV+r7#rqo-+w8h^K8I&Z`_Y9KL|h0jOlvC!V$ylU-OLo3lg>PPht|tW)OG?6ohUeW zs1mZ>q6FJlu&KeLml)3xyXkr8(zfN>)F#KN^5Px=0cpVP0l#FizeuUjD>PY3*-j91 z{}i{MU+ORQ=Jtl;bUcWHzMf@xmR~=1aNQWwOd6i4J#C1+_s;^MBQwlZyIF2qaeWpRXAp&kZqus`i9Ia7C;h4>_$UX7jIMc{_5upc+||EW{*=U{~m zF{?bWMIDyA^?%v@`vy+(ghj7VO%)%1f96bKr>z<}Xv+Rgoe7SgpU<))M-#ouXLfPz zGa$wwNN3Q~zk3$|e&k$X?bmH+|H{_(uibL~p-rA}K`U4meEwWpVphe2q_C6*F>n&3 z2I7(mw*&p?VfQMwCe_mo$GNr@JcxLso#aK5&JQ%kItLv3Y=tPJ7XdLbc_j=J=~bvI5|UZI*L2%MM+9!$(n!A4 zbs_x31$cZ$D2>a)S#o5s*>JYvcysedvldzMLG?qHQ{ftwG&>*fhoxm@AFIqW;&S(G zef;njkqFaqZFK7~pc-!-GafMhV$W9HTj_{ABR$8<|r*L*G}YqV5Zz7#md^1 zGcro`fag+8!|MPdTz>V{E^ykFgOF)yYU0rXDB(FPet8^t##q;>TW{kmuY=PdN+#EQ z4d+r5W2vfjz7ho-Za7~fU8N)C?u{Ggquji_knu8~t_%!FxoSXW2H=ysK<`u#o9VH% z$CR$rZp;xY%NEhGy{p=D&5K|0jf)|MLpP{|DZ}5t*_bUbySpAt&MA0^WORvd*42@9!%7qo^HSbNiAX z%|b5Bhllg&GBw(ETAAh=j!$4GUdQa6{}TBApE$Vx%ktBI+um8+3%;8TqC;pw>_awP z9{2f2>SWP0HA~5KtwL@`?X9gD#=9xro>*Daj&oc>g+w0Zef!QddsQa)jLLKHND`m3 zM|ZmcA>kpcF)QmuFB3e?0tDj-Nh0&Y`xIon&L^9B8XRzo;dwL4?pCBG^yo+m&%n~q zVR_M@hYja_z6IxUb93`hp?c-;2T8f=i|s$rOI_+DncX|<<8Zg) zl$qn<4PKk^O7~pHYNwu_H9&@^lXW1%>SqKSsR9JBlT+OHgDVO&+#DS9L)U?c`xF4B z#$P51J>~i#UoDJM*kGyTZ9stN-ppR^sO|L|^*3+(FlraQ<_PvazRv~iAFyQ;G&EG6 zJeoeo%|ynes^w~xqxlS1e@l}xr&UVp&qmWWNs4+Me8l^UlynO2h>Nb;7_rqXu}Z(F z-&$`Oj2FY%F*-gzrr~pXCL(gN)4#ALJQ>ZlHximBhNrAdf^@3o5P9*!ZMN@cU0u@Y z0gX~s_{yg`-Ox}%i_`Vh)tHdDircqt;oiC>>a$m-f8pg(aMZMsYSyb)hVD~MD(s18 zTv#T6@L7Yy{4~=zzE5_zdF~;8z8jIbPsTO%o}d z9u`)}mB0;IP8<0a7Dgj%qxA7(jLTeEtRn}IGdLv=(?*nXhocL%fwC?3wXd(}uhceP z%MZ4%FQ?xH1W4uY?es?&(6yP{tQ2Y@3O@X~{xAx0 zI7jke!@h=vMP~>LO*#+2i5r`nC2DTFU(Bax+~#**?@sShuW_-QsaADFs|qoXe*YFK zzfKX&22-#6@P@V-=Dgwiwgn$zp2N`_3qC8Kq#kByQ|-{=`7p=g*(H{-U4|G;ePQy5BQ+lq`LmfQ8pa z6q4(C&Ee+8Bs@AP37XQiEoxUOSy%_lC3kczC0Ax};J$%VANk`)-xPG}keh>xa;(s` z(L@%0-NnjEd)M&og?1^2dJmFK^&wc7u(>> z>kYgYHL5boJkf&AD-zc)sDO%aMi=r}Ei57v*`3ImTT?TEwQ^jdfvD%} zX6g)NL)g(_l0@j@;&QcYl|i$fDd_A*a3|msGGy)wu_7BS!RlgPKQ%NY>YkIa$H%=A zKar?^%NC76PYoiG&WDq?m?EX|49v_RMv&Aq=3z+I@H$vyb8CwfO{pnT$a>H3fO<L;(9&3| zx08@qJd#rGO{mXeB!6XPx24T*CLcFhr|g=I(0i?~KT3^Y2&K@;G2TrpAFMR+2Bz{e zm0__f&TQnM16Zr#9X4}gj`1t>7m=6f^Gp^+p*F=SZ>6>kg{Uv4RV|J|C`vYee4)#oY``^4glNp&=%I zM10iJ+KT!!Eh4~FZGFmiaqXHGPoA@b!%%)^UVe#j&oe(fJiH;BXWV96Ux3$otz29- zoV?d+{Z8)Tw2`M=3F#HN&I8IiJ8?|9dFJ`BVL~bq`iKq~kIh)FnL4eXHrq^*&9K5r z>nRa+PeR3NL|9lxn)iencaG{`VwF|ZvX4bQW`E2LEX)Hh{VX2fYCf7_6j(lRYDK|o ztyAQt(P*t(h9LeL1yFuXh;w z#{1@#1<+(eX$r$=MBOo)YOlBDl_L~W++J}i50XOjezHYc4(CO2?~ls&q@<(*%i=R0 zGtU-%6ym(Wo3B;R*q@)RvOWwMr4h8ZTr7I?_U>g~MFzWet&sWZ`-H~K-oy=UqC2!M z+vD|~y*{)O5v>G#jySV{ZaX{u$#iXLYdNY+k!%`Ixwx$Q!k9Ho{= ziRw|i@~R`f&*jk*9%q;@u^?%2-*8dVsejhg))xIr=-R9vqkw?vz%TNF5OoM7_rlxb z?Ynnu8g(38T(Wq{DohI`WpMfS=H{`Uk)EC&wUeaO)YQb8#Ga0|(xfC=U>JTwZk8wA z{4}B`P8$Pr$HCD}k|Fz;a~9S$eOpl{yGuPUyPkylm>o6tD@1^X1BA^y)cD*QlzPg_ z%CaTu_QJaiUVQA>wCs&b7*yX{0^_rB>MBoYY;4>Fd_6B8@a~AA3?|h)Vq#*u?F(^Q ziIq_F-V&0|?}c`yzFr}9kaki_^Ck+RFv#~IuBu97oFm=AxGQS4;sKlXk9t0wGn(^a z(vUJdTF;{X zbH*#{y(56fUYD)uDlC5ONuq^4ERvFe$-XBEf>8VR-xsf5y)u}ald^8^85wD9_1M1f zWl%`yKX8d)HX}sZv4oF}jnTEqTWKMf`*X=@B|yOGH)7bHfvOZ69)Bb4TtY@@(MPPY zL~ui*48(8a(Ym0gv1ctZ*+n{WQEY5I15S;)&85aYDo>zwS*zwIwFB+#AJUbCy${c} z1NZOLUB~(H0@w4@c$si@qQMY4W{c<|NGtaT z@*j^4s@i@~4eXe7;u5oZP7E+(WMqh=Kqd%>$3BOHPO(N72K@*KgTL6WdV_o|*{)8_ zAw8-i1c9K+yoR&dG*~)Wdn?%aWckN0R%K~3V+lt`N2JsH_g}A0@bvf?ak;a*(565m z;&ZmMJJ=SM^fWca#b)aFrATv2%S@GLZyUC>s>+`$+`ruLEq-8NARvVuE&K^@zFe?F zt-PQ46;+gOjMasR)L5IDbsgUTH2Z9e!=yXQ0zyVZQ&=VP!!WbdvD$cq`$tz(V^fld z=OMQ~8C%RteFpJt6=^gU#%1$+&FI~VfGLPduG>@fp4*>$xPNG~L8i;3G6}77N6sF9 zLUwnT+h|rmuyw+xgn$SBynB9rqugc&?tIF2wK=5BPUy)$1C3(U5^>yZf^1tGaXxTM z)zP#KW8?RRF=v(e|47@Y`hJoPL9FaUGBR89EMl?lcoB{4GXbIIS%cKHUs}MVLeUeGwa%Vx z1qcwEp}Tv|95?h5gk9%0tMHZH8ks>xOFdTv;E;>jllJLpc}}iqvn3eNLB#NG-0+oI zk+&TPEJlnCg}0~2NV>!!jQvmM%bEa?r8p*Q9hTop<%80pZIwG5*ho0hUBIi zm+d%C$DE!)6lB!)J$eI_ZA(q1Y+hQsy7Dqe;?g$fPq<MlT2!$;l5PZcRv`f#TAK~BYi=MCISk=F!S05O*i6-lmue`JMDVa; z9y32TGQ1`TtrJ3cx~x#%z!7HES!9(vpx{$c=5x~*1v~$HcJ>%Q%IoN8t*I^S&6__c z2&iGblaQdVegoH}di3aps$)wq>2b~dwQvQVJS54H48W94C!hy7^Hx%oHp=4e(K4tJ8K=f(o^%23_CPsToaaSRVikfo>K z_3-;u01QgRqVwLG&eB|JE1SkDtMKz94{qX9jO7y+mOPazY1kfvLQ>=lW&7naDOmVJ zqY|a%CucIuDCkYYCQQfgVU}OIxBP(=2v@!1f@EBlY zgQ&(wx1*h(M%?JF+XSnrYnRYDBKQM6Or>0ag2CaI5Jull_c4O*x@}ZG!{tA8Iz9{Uid-JLdE(&pX~*X4KAl z4H#&;F&p5#uKVlb$Xv4uUfY?eYVVb41TN6H`?hNL8HOc;Lc2)s--nqWZ;~r_MzSLR ziCQCo$KVo>XOrjUs)rT8Emer{@a~0u1-I{C@d}k|mb=so(?>i!u{7PWoHZ_^dLA%g z*FV712Z}uNwRj*?B^vYCTV=X_j!y4u%``ndYqoR*p9|yyMtFv~HcN(omynvnt8Cf( zxwv@KHQ#_h|G>b2fQL*UpU5nC1Dmk1DHupyYmy8V_SsX>EHMc-Rf6P^vFo~Hs2)+? zFVXKG09(}q6B!>EP)aH+P|s9D2*AVj=#2sn$T`y#%3!DNn4fj40hB=eIhta3`tl_( zgwhc^eM7Dz+=MxrMJu+OX&OK30m~0j$Bge{Uu9!CEQts__6}kL!xHgmm<8Uy!cP@p`8tkWU(x$jV1tsa5YtCSGb*`TARH$AA7f~d0smB7K#*qd6A$XSBoD0!)<^rz%D(E^j{Uec(0sff&8AbuBu55}NZLff!sGID zQy8`A@y2QgzyIF}{8ujhAPJ9!D`q4amNZm9`l_QVx^S6YQ@~hXR4zy}UoF>P(+ja0 z6cXZnvRM@p0yq79Xswl=9Ki`xWLe4ec z>3rVGYJqBK(ATe@z9ZQG{#S0UOp08cj*j-;N>{2*ic)SEmFO{ZSb%98A=Sm{XBQwB zY>Z?x%IVekD3cVCgmw{JQTEH_FmC8{rMuR?eM`=8x(q3(Mi`xYz_FXc`etSutFyDS z`nG0}tDCj8iII`DiHQaH2fkWZ7#P^tA5QuglhEVSQc`Y$5XEBc*pkD2r+x!SIcNcw zL7#CJf^B`?EOlN1`XKsAOUztq8@G7*Bl5@6sd4Q~dyj{6V`5{UU(giV(&%wneNgA5 zmu?jN%kz-BuWsa3#Y+WCQ8~~JP=oVYA(GjhTEMH!t@!CJ{Keh`!RWYL84i3w0RiKG zXb{RDL_cNvP-GIjUPYp}R)v*Xve$SSS_)7y`tKC!I`=6k;>;T^#xeahSQ=^}ue9Tj zMX&4XH|X;1>p@)`L^#_w=}NuvoC=Tar5Bpo6ofsnNy6Tp4Hw?GG26MqM`}trs`*Z< z?fp;v@j=*c9~=9KXJC9l5_o@YA@b;G02n9;G{f4+MPx*cM#MX%J@srA?3QqhkiAtw zJipSUPrV2t9Mr-|)2WJToGiDuwpxR3;>@rk0#9A?<;y|yPd&6@!gYHF0lhq&I<-j< z)I4(*-#-b_0=kRa{$ax5I?!fdLI!|&BZJ8bLD~w%PM=$8l-}E%$+SzpF6C{cuRq(0 zoB+AO5rFf^u7?)^nEvo|X0B8_(Cp*pRtYhEEAejq<|Zctk>kT_fJ|H}Q1DpyOO1wu z$iZzpmFzQxBJGSu#?T_F{HuN@CokVV9#xyMj8sZjVv6i^GpXh9+02@=-`xeJmR)=M zhfxm$Jn9Ebt*lr~+|Eyfq+al_ZMF9=FkgP$SXp6@d_+!8KqK%HuqTBi;fYH3IH!f* zj=Y7BhjYpxgB(V!_pU-6c5a(y=L8u?L?h~bwz?1aCHM{yOa;g> z73(&HDx)5>CnHzpIdV1{dABcMkSLV4{ua4WnkyP#8M(GTQyVEt7AMobp z=g(4o+KKYQ2x)|UPBz_3@RpoRf%{XYU9nV&(9C% zYJt)PH=_PAAqKf}S9>7XHI>iu%Cp`qc`i5;G`L@@N47bK$M$zU_Rjbl5k4r>74&~>XVnEga&sU@qU3jZs(N~9v_VJPyu>`3As?51Y;J1mfdyub~|BX214MM=CpkV(Y zi_h_%Dym$gT$WK;S@}E~dV1)+wx>FzRO3kNM+|xv01%Efr*H2fVGH@3haW%K*@6EcUk9acej1$F%eS6mta5&b zJ9fP>wBD`v*7Rz3f^6jel<9dj+@Ba|(?M!b z@RfRNYi+P56T8!Q>>}*>*QGl~5y+zP@$uk*WlE)^S^Coyy=QuTf-Yp`=&~oP9AI&A zfVtDx$`sr=zUm~ zEUF9D!#uIn=8t26%?VXG0)NZ(7-s5NTm&&N?_>2&V@nsv(CXbc$`7eNm*;EY(UzEY zOcr~`(fIhm+rqEmJY#}Hu^b+{fKI%gpFlEX(I_!R#!oki4|bV;{P;1N+Iw#WshsZ6 zlJfShg)2;_z-moIcZ!Tnx1i=vUM#bR8nGB3KR@cDR;I%C*4$R22tv|>$dw5EcXdrT zdCJ5j&6)6?ecs+anhk2LYm>q))~t{d2EdW5J8l>*xeZhlkeP+i2rA*t#rJd!3~;cp z$d=Xy2Rn}TP+5-Vv3dXMTDrBjQsZ=N*=TDuTtdF`Xh-4MX`*TR<*jHNQ4%rZj@3WO z4N&ASi_$|XzGr^zv2>@^!(INw;sIemKu94r1cE3Kzr6RC^NAb#KD=_;`Q+hjIfcOf zcAfbQ;XZm^N(lN2B#WQ=cSSR&D&W7i77?ifSqj1d z9S~NyowF1SnlX+k=0um4;;=)KN4)$%oe?|HoPT&|ExSx zaZPNT|G`bG>pVMjH(Y);`iqH)0j2kVni|D;m4x&~02L0%n1Mtz`FVNLQ7;aaC4UPzt2yXx5HQ+V> ze)K=D0nhyRFaPtJ|8DR9_j&#;xBmAv{}vwq$7}w3dH-JXzj_Nk;&%x@i|E$5_rAfw z`2eb%pNw}vFBiCdqWQ!y=E11*LJ;>F&JNTG6ib2j*0BA7oR(bZ&;Lti7XJH9{U14( zD*(?Wc+8(Z+E8pe?epF=mi^5iZVG1>jci5aJF12Gd7r?k&@lCKw1C}N*>RGW$iC#w zPf6Ab<%s9Z-0OWt?O5CO^$@|SlhAwk_zFJ74eWo0DazQvpPLoV)gS-SC^hixkPc~; z!z>G3yV~Y!JItzi!%R#9CxX0Bqr2D>#*$YWMe2MAv@pbCo6}|8E#p$Xa5n9_VuP+k zjfgRQk z<6CjEynU3=5vF8pGaoA~M!yG%1IH`t0vb7Xo*N)+l8Ei`uVUZu@y1^o!D3=fe#C%r zfl&b!&?8vq7)?mc&uy`svOWIAziK?_oPy8u8o>MqfU9?!lyK!`Ddp19rTQFhfqDs2 z=b()tsn=IhL5mMk`}1eAsai!H-L|k>%8H97ckXC~Bra(C?9~Nz;N`A}!GT7hZ1{-v zq&udSidiOD9u0G6*A*W0d<*)Do!DXy%CNIRoz1_Z8{zvo4FF_2`uG(F&2#~gM+?l z0p45h2zJ*;%$hqm$-1JLG)pc0C3s6rRzAz9+k$4iG7DYk$)w5P3Ty9=X<^%~&1_Ue zB&6bOcZG+ObI^C<@+CKSaYZSs$`b`8e82@oJodENqCuY=b4Pu2cF@I4oyp3z?Uvj@ zHNhLea4)SOQQh4?rdbJ?EvfO&R6t7-Cq3Z?%Ci}?3$@8%5;<(_P;Bk@s~xw05~oP? zUzcLF+iA}>$>jynOO~9s^5fju_B0d)-)@hQazvPga;v1B)T;~El!2x<%{q_$6V@6~ zh`a6P9Nsx>iD_QL7Z+;+Qh%nxBiKCX^E}%GXR#Jf`FJG1bsYZ_Y6WY*a*;~z7+vpk zs>>Dux9Bg@YKyuP-4Q3~S^5rMu_^S@>`$km|33fN#2Sm;*^vJ&nUP12AInn-@_Cla zDkLIMj7aWob_j4N2fBmbfK@sfy?Dv%oMBIg|$= zOe_h!Yq-?J%E}SOHc~*c@b{P8A-V>3=V%x-M)8?`+*Tx6i5GDl_uO6oj1ZJra?A_? z-Lj*OAbr=PIkcH^H1GMM3foiNC@;{gP~Eq0Z8jk7j3O2T-eJ8SpDyD<^1d6Gm(}L4n+`#eq4f_1wc3ld&9h4C_@+T}*&Ur_;YfmDS zb4kqDK+|UI8p3cn%~rR#&IvQ(x3;fpiTSNdRch7+h}BrlRYM*)yh+z~zcrG;NEWpZ zg_p~E)mt<>fZ-SEI@31$O7>PC$RF!$?R;$yk9_>RzI(`CoJsXX`icRgUdEB%&+`aa zjoZ=*CeGDy?<(YXxSt)Ev?VgS-iSDB3e1O)tqYB0ZE7r!2lH<5peM^W^e=??b5&UA z{EA-k$}~|62#k?2{g4E;0>mYd!Crtad6Xll1o;u45+=sXA+)nq4!5QY6cLXHSU~b} z%-QO+Ex3M^joQbxCzdwxdlgOYJBis9|g zpNbPMG^k2vg^zB2Vg`LBQV8J1FxazE7;0!JO#$n&kl~S3?H}1g>%hVCr;5b!=7!f+9ImtGSP(XFyOuFe-E!lSiRFXXd&?V9jf1lk(&*MgZ2!rlzsnlNfJNhR>x zQ%O++Z?}JCA0FrGPfSpchH&}@YALZcU@V@BICUR5k`+n~f_{jtY1?0ilc@|nRKoW1 zu){==0LLqN>=F(23kn)ZYkqUv_U9uyv=Z62dhho!_%VFmf53tt-1T(|B@9+C(5rRu zZgfT>yC<~=rs}X+s+kwMQ>c-gWeej2?6tG|4=71Vt&NPp*ap2{5f|_2-dh86^A~;} zTHA7mEJ52})mfb49l#EO_#tNSB%;Hg_**@-T)PPedZQg(69zq7+*n?pEV6M%r>Ylt z?1OevE+MyTV2d9>lKjBDNYzS1F_<}%eESxk77VdX=Nom+zrCBbUeO!Fx4BU#?8y&s z3`}+0r3D3IUQ6?BzrrVABoqv(nJ&RtA-r2|2M2Ehsl|o5A~(mYoKr5xXG$+)H1o^L z%Cf=-ipVT3n;7rq<+G%(#l(!Q#yk=i7nfrvrJ&~yH@F9fM-r?>(;aqYxn)Rp94Uz*cRbq5o$y} zpIR*Tr4jP**zF8yhQdNHIEC`5^xbv#rx$7DAiy^Q9L?mH9(buUB~U&@osp|P$|Lqw z#J5ea-r08S(-vsI?4Q=3D7XO_0H-Y~#_;VP1H5VOubWGZuh4 zU_^(?M5klugn0@w&LZ+5n?^2iOGmz*-a7DX{ejb-iL_Qe}8bCyvlJJL-)c(_qE5pu-=j&^NkykGdW?_1X!cJ!_7YT*^zjT{+M z@2{^lE>VESZ0&4AfhE?skwGY(Lu{VOphvdm+camdVqg!_sX2r@>n~2`N#`vD0`n{Lkt+Z3MC)Ow8~CFO}tWhfvT{K zAF6mi6-UYzqh5n?1Cg{a9`#%ms$`v_nDsg_N^S$-afYjF$hS~{Vrsw`v6Mmu=Qp+R z6e~(09CHtX5W1@<0q&+*>@w0;gg>e5-Ny2G3o~gvM@i zVV?ZNLgg{&STpnJ_U6a>u_@&9SlfGqcg|}NDVkrog#RSgtcD%AnM8gw*<0%9sJaWt zjM=EjsO|Iyr*Wb=I+rQSZ+`ZYxQ>D__{_xk7wO2=i&REO(^UknTu1G^B$>%0(WNKHur z{s3?+adpBK)kyc0U9SecXr3fXmUry1VrN$r0s2|03m6NBDK6fECljW%mia!MInQEh62Y-;58(lI9$el=Mb9 z72$JQ=Z*a%y1?v$7G~Fc5G&K6R@y~Y39;F0c^!0yhDHps zaogN>o0Wf^GpWDFY`m}YxdysC0o`%hFvai}1Cqa^w=$fcuU=7(dM+^4y*cUGJlvA4 zvdiVN^whbM$!~QSE;k!J3lN|?zT&K7Fk1Y?l~d)}v8^6kGzEFO-KY>fy~}>+x!BhX z0!#ies9XyXwXjyH!RgUops$71DHZIdwsw3H^!K4Jo<+3_<{Kz8-xn*h=>IJ?UZ~Ch zvaQj5-W-*j8lpr{Hk86Ewi)ax^tZ&!kcEih(Hc9rTZSbL-Emh+3-AiK>;HapoKLRv z+K1D$^23fK-jz^HFgFdBO5k{9Sx18-ebnik)$i-~Z(9SNZdO*44butAn=9QTti|*N-pgvDPfWI<%8)U8F zPRFQx5JN$^%MbDv7$;Qpkl>x7IJQy8SkS9BqYp{K%sm; zA);O7v$C_x34-o`w$eu}4Ye2QDc{UuQ~dYN6B;6!X#IpRLD;kY{EqI(YR{=6OX?=4 zYrhYVY2jhT#Xk=>L`O!V#P9&hCAfY8i0L}pp54^e%LP#pMArAW_x0@-@+hP3yEtSR zM2|(BEo8F$`TKX7lmKQ&e;LBASFcrNx*v-8zpNt+s~*>nbv16zM{P3rSC$tjA#=m?K)-8MKIch9% z>&Sn5g;?jG8X&iWY3!?4V0? z8!xiqcI939Nn$GMc95rBXyBL;+8NI1{n&+tiOB;QKqdB7-a5|mu~^sAo;}@(%bM`n zWD5i|I}NS{b|Z(~&pWGNVM^@tuyBw<;L!$zBqSrgA$0B@U(WU`w<+iVDR69p@!leE zqA9_OaM+XedoEA?9*J6CfPh0{BXR0oFCvT@Y$n9%v8)nqlPle1Lx5C@=i(`DW>$HAj2md3F?X(oax5>P_TUw!~UaWhCf*`2s#AfZKWp`9l-X z^|gV54LkOu2e9x7uYDdSF=V zhNo*!oR;fZK9D>ZtRovf=AD(PPO^IA_-xGj0m`uSIB zBvGRQCc;+JJHRfU-izItOYPmU=Nx5=i_AGc-Xwd1@Sd8NhbMS zI|TvbDieF@wHI%Z`t3n?oSpO9E<7UDyRmJGq5`c`Ylx6e5AQGrpIl(opQhVkc*{A; z-o+t?-@Vytn-rd-KJ)-J_R-xLG6Ad{If304beg|N&BW3OpD*-U1NFjY;P>)pbZ*+k zo3zjT_gna`>a9m%Fogb6sBj=jLgeaeMzOB|v*D1~N%QB{np|!t^O5;*=aoZb_}chH z%ecky8DlB~a&5c{uKI5_~XmNksvjg^*ug3+H$ieQtIS4II*P0n|aW4B%l7aBZC4fyp(y%+7PR%YQ z3!@%kARuzktMd?aIAe*F9{w#4mxzP8qWk7cp|gMJGH-{(Z~#^ehTtrEL%x3(@Ystd zPSMS0+PisfK9Z|(yc*>A_U;lyyRf<@u50ra^C?kW%ur$(C?;k;^9`H;uwUbBWxsTi z0HVQ7+`uyH{tpFuwh7;T0Wto3`GNwH>zA7RjJ+km4wSq0V&0rsbx!w~uGLD3;hi&U zMoM#gZE3Y_&sPJITdmmmeI@onrbwn2d|W#+ebK*vG_b!> z!lMzg%;`0|JgPhjz~lrE)%)g$31VsRX~A?Q7@A)%Jp+mt7>!E1JdAjpA+ylF1!oL3 zxQ#O;lC7He{z6v=#iCnv(O1kVo$km$w(ASv;XF~-$yrU2U8_t;O2 zJ|g7tBr_l1;(TPB?>l)74%={-(nC6io_BBGf*>nFGkT?eRvCsarv}E!D51NZ#ZCuT z3b|+q?%nJB#iEp9{8NRBy!8)&%?7AS^dYjQ%r+4P_wqXT8K=$}va}$!kaBYB2IdT! zA}?RZ$}X?!uPo1R&Tq!BC8S39CPbwArurrz5gY6GqLAkr!p#|w{Y8#(7=fT# z!Bk4=IexS)9_`(wURG~ewBXnYpUc$&s_crz+ky{T$@V@NDmQ8R{o{w+#37Zi`}9ib z<&eEEchfui2lwwY`F+SyVS=M(M2jRZ`xO=Y-5pGlxZVBeV^2?R0pQlfGcYt{i3CvJ zVRYLW%{kwaTd|PoVSCi1wV&mMy)Y8{>X07%97RzTx*W2>WveZjuLiwyS8Dxm7l7sp z%ST2(J7+20EZb-<${!CyHp-zC^+z& z8>*6N`L@W*$!R^B(;uRR74C{`j}(TS929`jXenzKAPCE-**xN}jCNuTe}#xk$PdX4 zjtDM|kP*lPX9FC@=G0M9KoZx;h%TFKcc{)cAqCX!lK5TU;w7JZXzhI?V|LqWSM!d_ z)2;~Nl2!L`9W{O^!`BBU4`Wdb?m#&Pk=gunZPog4!BfB0iBwXR>g-7F1FozuRjtJEI(8A`EArn|`d}B0yH^P86_gRN4LuI&3cX^)xI|1bX}qum5H77BbuckP{2XsE5q)^=jO`fc=`U z_x1W*b9`7J;JORbrib)b$^&&sR!){r{HkUR{dDBEl%C6*K#8W>v<6+AOB~0EH`>xQ zLN$3WK#|>v1l8Z`S6S%m8D?Zl9nRQhPFLc09=UHJ`{4YPGrG$-`&)Cs-CKV>#_>@>NBjb! zACM}x`RzdeJ?KWiw>HI9Dgd`$pIhbtKK-Mw2uRdue!D!!8q@S|WFstgCzDIX%C#W0 zY4T*(VEclAo5sK%fg$-!uh{v4>wr2B+_dr(j7!u(t=2YeZY~xLd6u|zY!g6T>lOG_n?;=_i8K!B^U!enG+t+K=pggu^*6g1=Mc?e2c zPllDeT(M^7zUq%;6H1@)@u_6izW-AT+f`xHtKoL){togA@FyUZ65-(~H!4iV4NI(Z z!;p`NS3P_v<1(dls4_B>o=~=Deo%qhe#VF=A5gf+!WnOigxT za2Uu-HC(p-rzX|x@b&vAzQ>5Oz6^tpPyf{n8#z%_D@RuI+h|@U`$qq_QZ)3 zK3mtts=&Dn%?^3vcjp-61-{_(v}DDNZiWU2d#udFk=ND^DzMY=IW+#{5LDZfjl1_U zHhuIeEWVYt=&5`T4NGhnr%Swv+_Jamb{HRLL<7|BgQyyNaAqDYCvsb7BUe{}Wf3&3 zP?Vp48If%uVl}9~406@`g}2?TqILFKOf%{gti^{M-B6F)8^aHrYU(xt zR8C_D0<8&l_{JYQf%R{G=_yxA7a}lr1?XfFL|H)Y6|N82N&jVj%ifmJ|3kpl*$BDuKqGX zFvEM-2e*EX#k-gkh*BVOSB>vJAD~+O5H~Xeg~0DXgX!%Rg5v^Xw6w953zeC(>mW9P z23=&XJQb%A3woYwF+uQ)T5!aLV~3Zpp~ZjxJW&)JcXKqe)5f`9Y2&G@lWEA)$oIso znY>Sya5aI<8Jp=w#Sv~qs*Up7AY52plQ{vuJV}9~Il6$BA`R6p8Og}lm z`lp%`qQ&Al{wN58d5k`b7Tth&XXTEOs+xTI8+|Uq9j%T+{RW5$tp}V}AQ59xG7@{a zJGLKZEKkW9+oemxTOxma06qnsp~5;N<0z<^>X)mjdhxzyWFxxDk32KTY<83I28pPR z>iEZn-jyPAkpRDdtd$vQcCbe@u;YbM`S~j8AFj91b|1PQj}dR$X%dW-L%(_RU@JTB z-PST!6detX?Z{VYv^otf`@*6%-{NjX8FrKGJbNs+7L@XYMu6L*zGsdcN@Tlf_KJJC z`}4y-)t?(i?cctAlKaV{!G1V2U;nG7(kVJ0GGTR`d+Lu}7H~5{__{3HZHd+W;z!QI zNP+luVS>OQ=C-$#N}i%(Stcki1NpHmbpP>aP8b9nhUE z7cYgvv+r>h;gy7sw~=`l|Eeq^xukDiJ9can0AGy(jjX~B7yn}U{&N=|uI2wdL<0XM zNciu2i6 z&wu{VUXvqg7>})R1k*~2vx}rxYsCPa=P@S*<+ToU52#+r>FDM;=cAz)dN|@LDq+}{ zb+iJU252r0_Voo;-vsgS6W1U0^{(^ce8#6kqZR31G{py2v%!mcb7SeS!66|#t-%lp zj*}G6lzs(hOE-O`FF9S$9RQepnby_H9U3e!9!blW;^b^bE}-icMajm-#upc?!*ykH zG?kMpS%rk8NiSWy=CIm$q0IpuKC+NDJ8af!FgH?eHKeOiy|Y;54M2=+gH$;z`<2VN z&WB@opp9%U_UR-CCEcu7uJ+s%U}b#)lqwJe9Qtg&C4b&_c=KFLZmxd*;4elmRsC(C zjIC4UvVP2SCwyzwdnH^V-&zqlAvig#xSBEWc0knZYL^S1XN-p_RzKaEzP z@jpLfVQjN0KC;F!A2Mp3u3znjllRx;OyJ*slOXPKxCT{eU|JDo`vQ?U#9U7i`ofJ9 zMD2hk=4NWjzqA9K`o(oUJ$4(uG==FO?(yvI9e1d%F$R`h=Cn(^K;}TIyxw9sI4$db z)J-)+eXkX37_73GU!ub;>FVkh+4Q|?c<&6j2lkKLxSU{TTJK*qH(8*MviqmKQ*c?r zX|Y$1d2cV4czVaanL zE#kA=!ipLV4UN(@@wB#2?PS*_;smT}dc#gev?cGBAFbq*rjK^|pbzi^Pi-u^QJUWfBiBwO)(Hp8VsS~1z$^Z3eCbqVL% zfL9>a4vxJ__n1O83!%7E7Cg{nGnl(0^s@OQ5BmKXMlQor39}jpM(O42^yjFXS$t>O zu|o&`%7E7s@@gGZk=jfDTx{D}kpKDf*lH)Wjh;X^<loO&Iw=E`@Fx3^!# zDae$aCc1NaJEyrlHtE8~o;OI2X%~7XwSK37Q^T;zvk)Gvfd?71If6qd_n$Z|e zC3EW+e4msB{c3MuM{?IG$3Uu()oly4p;Z6PrZDky(`3iyPKnR^bs0RTW%ng-tX^tp zd{JPF(mgT?sXLUa_aiTLH0|^@O;U$awP`AOZF94FvZP=7J0B1VK!Oc+c>#%U0LDgLfaq9riZ>!nT@9BL7BgbTXBY z5;Yv8d6v=L*$R~FCVROD4a@l0Kwkb%C^S$?0g0+)yWuL9L-z@f`K<%S zRAvkACpotA7SG|qa2Zx@LH$`&FF{g^^AUc&r8<4-_YP;^u#67Yr85(okT&Zp{@FY> zKK7BTcs7OAD{f!Xi)Rdq=al0V1Ux^+v(1+<4phVeC%FP%?Kp*Wig(rZIRgY=A_cUAYh#@( zR>Ev`$b$^>Jy{a}C@TRuWils_i);08q($ZI*^6K;EY2_H z;pM%8fa8PYl>Z|ikDvARS@?g80_f&ciE$ZG5Dd}pYy95tT-*r5Qq@bNL0a+JNx-+= zDP%$Kd|8roLgZ-b>BefY8Ua$ldJVhu1cR@t^6n?uDoj2q(3tllryt<&QR?YHt`KaE zA`}RT|9Z-j_zFLcw{NB51u-$AS}q4Y??biDGIsp#K2PlX8Um+o{z&_a8k`4Og%7Ms z^^@$Qe-H|hJOc_F5=DHwK~YiYN|a4)X>|d850=i==|pi}o)JL;dKXOkiJieT?MLn7 zX5d>Kew>ZPMrq+LQe?CMIjQDDX^<2`DnTGK>#q!ezF(jRWB_pCA_5q1vH+YFd4D;V zKTE4)_$h8!3VJJ|HnqQM{nl<{zhAl(23{O;gp=(^c+;t4M}6+kCgX$kikI=8Vs5+8 z!kdPUm!$@0EH@4WY554XIIW@Hsuq=Q|IC-M_MO%Dd zSJ%X_Pd&gZ`U*`o*`szt^}?iwgCyPei^x-SuPD#nvrf_%PP>VrNw-=&YX|04II8lF zr!p}ytxvV86zZZWaoOtZ(5a^oe=UhwdbF(v=i?WzlO`QSZqNodA1HYFeHWW{Sy7o4 z;|$jy1TAM6i4 zLRu=S!|mC10Mzsi3~Vik?)L?0v5?s~y0};lWUfN{m}F}fJlXL#^`e1|$6ScfQLA4V zbX+<7&Js-5r8K0JhQUQi3fhpJxzH4MbKbT1Bgt!|Q{|^8tZ4!dK);Srx4bULaXwr= z6pV|2OZ`Rpjy!8Yr`W2#Tb0GkXOGYvE-bYuV(x`Y1 z)&1Z_UBSMu7SmrJR7T8wlF!ks_FiA=Mv=geU*dYCf^VF}7EZS1ie5gX0-ekv9jj`r zpn!l?ajM`OdYVIOhzrXe#1fUi! zBPN=YG}w&G9_UarG7MWuA-aa%gsqlmI2Sh`XZau~i;N zDzy8BTDYx^cW3`p>vKC<4^$WrOvRr~2{+Th%v&@5m@TA^&}|xQRwungb0w!uOZwr% zGmPLyKrV0(A!lOAQ|=#%u?aL>Jad6Vtw1|2{HHtF)lK+7b_$uNS|Zdu-o!aZgGf*P zeM!8+A;c8JJ=DN{m9gynmWW9znDQ0f0fNN{)ly@+{v#mLAEXghdKd zYT2s`A@ASk8l*Z*U(5;Dfl?KM^yTOnKZtU|A5w6Ab!@A-&CxweY1;f}wO)0IQT1JD zoWsG?5S+F4j?PMlBD2eT1WUHKj9mg8!~znAvpQ8yu+)XLjz8f}i-yV(ITO?nm<2Q~ zJNCDR{B_o&i_LYb6+rPZ%F8OGPWsFuY(l;jLcLkR7uG3-PG!BnTgwR7#i$Tno$em2 zpD8k08AMzffBm-a?z-16I|~5|ZfUsMk?;)K=Hz2zQS%5=Q2hFxozP&V^dP&Y$oCZD zOsTe!wS$mEA<+MpXotOp^{ZRxZDpwiWhn~8)gvR(;giIYdQ47mu+q887b1oE*kYyc zTG_hgk8?E7G4Ai5+`T{pk(Ja?@y|WlCgrveCC> zIvj(fGngsHpi_$1k2=fv0)zbMH4ehEA90Q`t%>E4T|D&VWRHr(cB<@;{Ylo~z9qG5 zw*UoCr?sP6;Rwg+Mq5iuumjW6(n8Un+qc)_&&+s6TaRt8X0g*t)S?)2wXvh4mL1(M z{rT=)-aXTzM~b(jK5qQ#sjCwi?fF^HVK-dHqS%&*lO_!V$Sp{UU6&*_$Ve#>3Mlkm z-QMeA186KR%$Rfg3VCgf4WE>ySbVm`f~RlTkM`}d+=NzlD~R0)!>PB|GthHn02cQF zuP|Y${hm$e2&6Q=+V3F0UefbH%Ym_3X0$BIz%Jb5Q#7Og*p6J+)?~{Xku2qY-%0#*}LWW z&nPuH>~5%CeepWQtf-Zx%4jtO-ds>X#jt58#N)p{G&{&!gcNFb^g9dVk93>zBX;#8 zFBBM!5X{P(es4cNbKo3IBcVO($J8!Q9{1_7@N1-U<;U}A)!V+my%l>T|H#8idsBO# z^qGwfcopd{6CXIkl`9Zf2{TF;$~ZdF==GPIwK}XHNefb0J!TMZCnzwuk=&wr5j>6!hLy!Vw0jQ{% z)!E>&iyb}a^gQub0@$pexPvP-EFF&e&W;pU417fS-l#c@f!9claYt4c*lR4> zi>efuoBoUzKb?gZsB)#(p7&VqXQJ?D7$+sGbo!LPwr(uWa`*{_lbHv%*WS|i>{5B= z5c-tDiu^DYkl64sNP4aW1_npTbqI^3*)V_+&11i-)Jo9S-_Oq~*S|5EeMzTE9m>X7 ziV|`Jm%Ovttn8Mpf@v;aS7YPP!mH`$z9CBu1GMVSF+%H0z+tB6Qbjp|W@~KH%I^&V zKu=cV^YheKuSW7a))}+3$;dnu78X%oyOUKIW+O^~KNQ2s=+&Ia_8p=wI=;> zPPRsQt(AQ4K&^>jGt0_EloLe=@E#2hWR>FXim9CrV)TCnb+wD=q3ix5>cq4rXhV)&=s~PI8AJYn}fh^F}7=4#mAStlH>zC@HFKQ zdOJ3#l&QmAfl+h0f;VDuu>-*BfsVs?=p@gsdJBAM%kSKa|m*?@&r*h z>0+s#1NHydgoIhQD`<6gXzCZ*ju(Fe&5W;EsjAN|;kPYDqvUB7gt`~#`F9GFks}XM zS%T@~`Q_`nmhf;O_`H=88#JKn%K2=NByKh3k`teM=-AZAPY0^W+@q-EH2t{C@wrVd z%i@V+YjHfUeu*`yLts;6f7rk~wYNiJZfZd=idXljfS3-eoAc!AuS55e1EN^tHS$3v zIWY#Ixn1S;d`IgWESkh?^uFYf(BKva8Mbf#8fro7Mx7O*Ylyp|5*QVw(CFpG5e_wz9E%yr=D5Tr47 z<4eln&32#@MQ`vK!W;>!^p79!!e`!?WHNvFJLGrx+(R)HG6wN02#`iiB(p=2&|3}Y zxt++-#qx{U`h_ZG-Lu1`HsE=o-~zPO-hN-iyO8*e)rjTohQb)G*rd#ZRRY;y>E0*u z6bOYxl;ISG5?6`K_!hK)V8Fyz%T?>Y0u8LH8wfP~H?G>3I|s=;Zt2H)<(QVi5Q8uk z75Rz`3!Dly!f9R{K4@Gg^N0PzOTUi&k<{S!_4S2=V;x=%Ek_ykuBQN--*GWzERPPQ zT5k=C47Z)RVDc2Ro~_eo-nABgy$kB8g;4rWR!23)2vS*+J=LE<`(JEiWb2FOJG9o@ zhld4RTNkSTw4s{q*fi7bgKQ>gQeG?jNgc?Uwqx1)3Gv9{fPhC}yDcsjpqerT@9BdF zj8K6ACq^*jti$?<0O9o*4$9Mz$F{|$PdPw%eShwb_F6shLIjDP{u^;N z0(Pp)J54(vxLK-~kv z{-1w5SIM?718+_8#&5|RPiQTFLK$$j(fQ<_%hA#G2`DpRWYuIzM3sb(b)6eg;|6CG zT=2T2z(_X0yY_Zx9oTg8q^qF+$!IeY z$#7(P>H}3PE!e(tLQ`^U)@ts_a)6ua+sOdME|gdwTK50KsAra*)6y@0sYkjxF&p%b zQ-pHmN@l&R>i!PLR9|)v`D{yfEnrMOUft{oV?oCCMh9dXxSXy z@A`G~B`gC*X%UusN`LP#sKAd6$m zjDEr@N5!pQ`Q9w>xnDePkRxBm zrn_@>(7<6gDy%Gw0OdwXd765r?NDj*Skm71Hob^@M}MQ-oa_1z%sOZyz;dqnJaoRg zesFfYNOHK$@d##kFxSaAlrBIxCAp0Almze)2Z#J3$8i{N(zb@KEOY$`JgEr|B8O!( zS%A1v{akVqimitDuvUPrIx_M2qL~FPx+!BPg=V-*&=yCpiTaPHcGF|wt$hvc4GlFl z!_a#k9tPH0cU^yfLEE?XHp2?tg6B4?DW^<$`ruOOw(U(XJb=>e`)b>&s609vb4N!E>s4bh2hl}+`7a-WirUFZ!CF82&EU718YSLavB}8S zP8rO`?$?coYpePe7nfoD@7a@gprF0>VMlfG)z(-P-4!9kgP!sd!SOv`Y2=^_`?+5rBhK(*WBdMH{^4qQ!=%FFnWBXF`et_dY#|Ub} z@Z~(cl<@J4)-#M}Cz@aYfq)#XKX{n7*L89A7p6IY3AW)7D|9Fs%Nv?6b>dJcTP z+Pb>8c9m)%KlmGbU~D3f6K}_eAN3r0!zGh5Pwniite!ms$R61>3C5NJ+|i!H^z3@f zI7=Hy!yuIt;NcUqp8-{Hwie-H3vCbH@dn&+ylsg*y6f$Q`fN}-H*etQT@j>ywfCooOx1WP7=P>p zX*s-Tdv5(AQrj=};>9yV$dz6I;Q~Gj^o~f?p|CN#o$CM-V?y66K+7Bl*$(9CuT0ts zOikIymE!5CsbkoD>YFjpI2o_l4Ha@3TfolaINYM)3iGSl2f=NKtBU8bBV@eYK^lSqLns8%{0~DyLUgL#WIa7A60(B)O5LG5Hf_xhGQ zqUgfH9$(i0F*>zbhGi57AA}n$D6-vsDn{v%2gcVWyfY2LZ>zoY4Qh$Ush3}cY7IW)*G z*rGa)wBR;?FZi(H6ilDqpavCW!vs%UaIo`i_|J_&oZ*3tnAf3dMz>vim6?J<_UF$` zckYM@2(-4~b>{T&LoFI}iJE0!voz6&H~VhB}72TzO5N8gGH@7joUT5d=Cp50}gAz@VNVUL1EccGOB z>T;Zd<_%<$bk-un7k3S1Gcy+Bo2egY2I;!8Fr^BRcmjKd@9y1SFj%H~*Z}PSgFQBp zcb;I>&-&H2cFVZ?g5t10Ujyjf#7t}7>fA9{yvB-G`OO=fPo3O#!Z*ZSU&@83{nf$N z(4RwbtkF|0^&88{$U7xYJ2HT9oP0mo5v>FhCHya}!2~Brr$;J}Sbaaf^Hx}^)q3_6 z6hLh~7MW2-%55V|_#Q}0RkAdA7z=SYMayJ)D(-B}EJXzcP(Fbe7|Ndp1|6kkLi0QR zkb_IUSWx&QiAYzPTe=4I7yYsW1yYq8TBa9I0kCCeXQjkqLUB?f)c^$=`OrfcZqE;mP_cw>`!<6PU-;W8Ibi0xbhviVs0S zoyc)i<-3^dE>?g@w3h;(g;Qh;C17UZS;2!Xr)S6m>DC|clSuA(PKA;llN_kC@sQWF z?D|~)ae1T*Pv=^;Nl!yXm9+o-jnUgIL35)5)zntQ@5ptZLeuYC;4u$Qm}_x6vHy

&|0N3UpzSH*?*CQ;G`O58g z>dF+$O%<+Lnpve9x8v*UKKb+F;ql<|Q+vY9HqS(oxMEwk^d?d?7g+gtX0*+7*$ z0V3fsFSS=2)t%-RrG=4iedpj9Px}U~W3CAFvxiZ-^fWY^g|6Jde^^S9gqR5mp#8Nv z;{f;KFN7!Lwp>FYj_b_SsjLzgF^>`R-U9g{aR?459Utewu))?W!6j`ST_?+)W=MtO zhB?k&`IYKea|?qn>4aLXbnZA`+*dxyZo-+nXz5QjgYa__e^1EtfSWSf@oIQUNRSXxfL zNpAf0%O1RI1|Fd$9!IMsm|?ZL3rYj0bP%pf;FgSJ)xCi^1+)*W^(HfP>Db0k16Sc_ zXivF(i=GDL<%H(^cV~x=3^g*Ju-_3wluyiUio!S@m;-t5?)DxH7F345!1o1$vK1l# zI%l_c@lqsCcA(KqDZI!axz{l5D8k|o#*UX;C%|RqHCTFNUN<-i1QO7Og3V*0JKv<% zglOE(R#^m_RkP050_{;xZ~%*(yC&)UtR|cvY7}bq7lD4-33G(Dt_gL6XSB3aX1Xmw zZN!6|9sgI*%FiAn$j^TX5qZb(%ZpXSW6q<|RON_bL(_C-`Dw-7HzYs0x^A`Wb%g)^ zeHZ0U@poT-OUedh||{9I=#P&*I-AR`|1TG^gLkr0ihLYA)=1`;Jp z?0R53g8U$Ty%OY?`%{e;BkqhpU=yfYVqwEU7SNIn742ooZ6V?Q@=_7e{(&=$w*O{S zAY09n|)PG>?xhp>$C~j%+tp zniQ6=@UnQVzu!b>;g}VY^Hj4)Hs@l+^_UrdfCJK~+KXtJu9AY^63Tv&|JMCL@TkN2 z=ruPOa5gi~5l4x3qg@-99sFgbqv2fIF-Z@hQw0OJIGOwo&rg;Q3&~xBP>dy(JtIh< z@BOG1dLyD+3Z-9f%F6>K0SFO z2I@^1I0*_^q8=p4%Gl<%Z?EghkMqluAU$y-z|TsFnD`a#w(I*j{)(A<-m9aU3@d*s zaCJ^epMcwT>#+(9PAwkthmeYb>N@Dui>y@v*9zOL0h=zs&p+E)IRqO83fvys+d4`5 zMg!i<{YWQ3lE52#fU*ppN9MSH_;9{5BXkygPSBw{eKEN!S0go-Ue{dkgIw2N{XK|$ z+oEr$ZV{%MlWw<90;%B;_RcKUo+w9T``G^uZ5ahTkHayR6sSbI6Gqjl28G$(q7Rc6 z0benyQ|#*`TJ03b)LDmgd;k`~n4IAvm+u!DeYJXuZQ=!gC(S>#!`W|bFCECx3pvb< zvy50!ypwyQp8&t#@gql+3_A5r^S=?6Nw7KRl_ip4p5zC&CQzW~=eI-ZSX+BDP4FB1 zN57^X$YEgwE`I``W&hWLExhoo{~v=bRNd#dzimlgmN*O!_+U9$W~NFP+4Mr>*|vv% zwX+tf%G>~Thv&fwW+fy0`PP%5YC@%chbAjExEJjW9C>*`QV+gzOFX6;Jkg-dZrt$Z z^n^)m5P&zth{{gBH-Q^$7?2SgKlZ@g?|t~j@7$ELLs8FRLw`>gs34EF8+uysrOyE3 zB%24g8PMigyjnQ{(tEaUi8D|xa%g*FT(5+RIsLpDpggz`&LC>GkC`J9#$#Xe_O=!# z)8c|a(81vg!pXrepDQW4*-TA|Gf{O)O`H0IMX!r{wSTTBZ*#cq;bM!LL2eNiBq5jQ z}r^^@6zJF)!6SUtg@HrgGbIj%Mi~m@?g6 zEXJ3nDxSAYfdr;I)3Cj}+x=&J3nCy*M1TU}D=U2n@Fhm+w$_}^y9Lz8p6u;^?4NGl z;rL$P5}AH(vTdl?h*4B~s$7hqWw*)98u-!W83Jpw->E7!VBFec7vV`o2 z^d9TpD$mU+xhrxO)wTy?cs(UMq8u?J!g5fWK`yNk1_!4ds;J4+9_Qz~vk$g$z|}Z> z#mxgk43s4RT$r2-&jtJtSYxhAeDyDl)5*Q26EBb*)tpY%e7)Ma{tzSzICE9ma{Ubd ztEgvX0GfZ*w%u>1la;=ynKK|_x;DskY8`wv0>-BaFLbMq@@=iay9lE**^x%)+>XIz zdVR7fnpKz8$ej$we8JMRb!HW%$Dvd41bi414sM~XIgkf})C_huCK!vx z7goj^6_4I1W=-4m7xz4Q_QlOjZ(!Cj#>YqM;oGfI+6$Ii!CHqylICFYqKs*PtgGJ} z8Vcxp)wLkj6f?pC#^b%#bo-1{6ORV%F#I0TmmgTYh>v#%Y{?`I=%*1F0^!9cEn zli4C~t$u9w%*o<+gpGS5QfYwen)wkIgj*6Tah`qyO!h&XmFmrp#6l>ohVyGJ**-E_ z^m(9{b=>EQ;Ed;GyudNnWe%MRmMy!FO)ui}3qa_yWw+AGM!_NL-`vxK#P57XC4k+9i6j`t%=o4zN}hh+O?-3 zGrw6*S zSect^=j&tm?QpqWkp&=oc5DJM^PrD;8 zi70>dBZo1;;slt8YW18K-~bR)RyRSWjavl5Pjhd-cE%`2boSsU@bZuot?zpww7<*M zc=E2v)AnSkRCo6*70e6Fr_kJhkV{$=3t~H{QuO!sezEeO3as+V;1pw8Tf+`0g={rA z)x>kVUL+wAwb-pi&&RKU!$(Iq*3JNTca$C2IUq6?cAM(JIL5$7Rbbg(1lt{o@jwAv zoDI?n*>Zs}5eE1{lQ~j``rQ6yu_liluu-D%P>s@az5ct>F^hTAirMq0?q8b5LzD^@ z%fI}arG0IDuUnoTrXyoRldx@VpD^u))m|_i_D}NamGUiys1Q|{f_vG3s6GCEdSCpB z4#*5UBn7y%i(8w!oVq72-OJAhBN>5K$Yn6VJBlN1*ZG!F_3M-V$`Cx2IT!9G{`EKK ztYggn20{$|L6LdSL9bf@dabe4<}}u7!S&6*u(d6 z+%ZkTA5=@MU>H-mTRqIW={$|Swosiu2Gz8jzOP>U0p8KkR3c5tt#biELF?;eUEbu3 zieRz4r`N532!NS~>a2U4b7}5ATWp7ASF8kJr*lKS6@;~eE8ZF$QTXQgLVFE!?UJbu z`++h)sDa@YdaJ=-_~nbalwXRx{}lAK5`~OEhvp>{#z1-AuYoh2gdkox!?DZy$M3tP z_bmR+-^kwl-;lpCEAuWc4wF8160|rU1HdcY5?_triXofpM87nJ7z=bWVLs%{W7M4UOFI_VaNIXSv4vB*C$ z(4>d17na^x~`07R)x5srXw(&05DM_6m~yVi6#6JcbRDfapjapc>faj~0!stBv^5-oafZ{0fDo;~;M*(l(K9-uNCkmF4~%bK};o>R3Nezl*Z5|~i~0^$y1gS95} z>55n5xs_Y7O=ij{?DmgYknIdFb>~0y5y4;HlDSSz?FwnWV@xg(-uQ3aeFWk^n#rej zdf;KpywU`ON8|ZipTtUH+c+HTpknUk%a}r{Hp8zx0CoZl&Tq$`U{nNPBAPJ2Hraa5 zV~rHUkOyn4v|Kne@$MdYI8p8w5_p}}qZMX5zZ%1VY9+7$@>RWKux(0$T-)(htsH5L|Ce;om{MIbvqLydLQZOi#M zui)(HYw{Gkwu`b#iSxbbkMnDhglVlRhk zS81`@xaqb&@%t!VVN54C&3~__o^oKL>Vl;4Z~-`?XG>Qgv$)L3z+2=BstKZV&o$TU z7KlXnzNp#2wpypk<5J0f+HZKNNBdy97#N!u7KViFY=>5H!^o95A>(IxTsgoMitj+x53y4k|^? zFwWsKRjZsxFUm72w@is)=AcqoKBufZ3^~0{N7i>Wyt8PR6k`e)#}a@f!ID`dHq{b9ptacT&3 zyjkj*VA;KVdI69-gUIa{-&PtN(}USn@7mSk(e*uWWu9ShwHqvUU46d^3S{O8U`&2} z7~u|BjxL?LzJ_O06PF4rwYoA5Rn*LljVJLrHuF=P(88_;HVk;@hmji_p^~`Gg^n`t z#F7-}h3}Gtot}C`IsOzL%^yk2t+!F4Ti2AFG*eEZNf`km+MR3SkQxPeray4w7I$}} z1_b3n=K=kg$@Pp6NqiY*)7_e9L~hBylgokmRXWh$0J|!<*F)fDI=2ne$7iM)W*Am} zwn?rO8r^?lA3#19(Pp-M+e+sA+)g1`YU|hDGtTp(Yr{S)P$_JVx_Qow{@6Z+6$3q_ z9pkbaKoY54^axFFH57B#PUaK)<+U@n2ml>*b`9c*WMesul?rM*d;`h}TE6FvU~N4& z(S#Nkk0@a``n`Chqg!lR5T|ddufmzJ!Q>7_W{hL6ANsUHaEoE*gD5Z0sl6 zk1C_2{GSswoM*TmP=u2!lZI6y6J5%sqyMp6CaiCiy1KfAEZWAdFA4LG&++WVL`l^v zDA_8D5Uz)Px+Txt(^Ad7FqD&1|I-S%8s{%wob4*s8Oym9==PP!*g^u)LtxdKwXTvN zBLqYb=dpmu>_*0%gAO+}>06T*Y&<>pKa@7Lf@_Uw7`KeK=zl2bqct}4d)5i+av0V8 z4?K-lo}Qjh?9Knof9iR__$JRMl3c=}wkXE?5PDP4&kpM%)F}N44IYTe*6%5lCn5qo zSNz9bj?VqgS5q~VEVU@xxVWUfJ}lgP>~%K_7BmTi)n@fj*IAPES~(-Qh)pEh^cVZs&_ z<>i8BO-oYsO4S?d$y?ta9TbHHf}G?1BYmj;DNQdkz;WcNqGAVP263x7Auk?+*Oe8Z z2wl1-D5xd}g5a*eo9-8AU5`GuLCdY@K*R`%(Wf~~Rozj293qo+KY{APK#}>E&RIhe zzgvX$)dt?_>HPv#X`T|H;H!h{Cx<@*O|jUf{r4->@k)^kGx)&j6T}qLQDw@J>XI8_ zs|-5YfofCiT318LQJfkn#5e?SINS!MI#|^>PpF!;Ce{o#-zmk1&=0#dG&+yVZfUhX zEHHIZOsJFak$0z_-r!Q21^(J*qojQkLr~(iPM|rlrv@Ok85SscI!Fd;_mfju4KlwB z)U>eaJPVegkFqG^F>8LBraKmBcPnpe=Q^OQ%OOnPku-^{xa-0qf=d&p$)Q{g;4r*68{j$`PkSwm}h^ zu-nM2YXi>kLyMbtn$46GR>q5V#t&`Vr6%e)t{0B6_h2>lcoLUg+=?s1HDxkvQDWh?1hUvf{wX zRXQ$Tn!GLR!*B@9-@8lxaaDOIc*@FR39k&+;Vn3fOb2T4IlbUB{>w5yD*}da7 zze~O>vh<_X;Fw7^Eb%;uLVj8`&%_}I>==-L*cN1I0u_sNnfQ)*W_Gg(gKo+{ zG(nI0HP?*3AOJ*4AJQCzus_O*+H1j&}rz(9+h zg7(}4S*_f@7td_F(;t3syduYO#bL5Jovinw#p2%PLhL89LdRVv`DtbTJAL_k9|KCt zgreAVZcW-D>dv=DHO7cIE_Lk4_uRSD473ZaJQq9(nOF2dF(dn&Vu*Dd%Lm$zl6G8G zoWx)(vl6$wG`;K7W0~HfbNSk}_)itm?QeI_RZMf~(}=$`g$^eC5o8?y?BW1!Xg74& zb;fsZrwLdF>{?~9YVNXnbXF7;(1O0#9&e6~|L102g%{mAvj_}fF`q;NVHo^N$nXQf znQJUd*JFBYhB3%F0{eYwAZB>)43(C^z#S$g-YG7Ap7$jw^5s(seivSMPg`Ln&ir%A zFW`~A{V%f4sbsOmOY{etY;ECv{8}N|@A$1W^0c9v16<(y7^5FW25)3?RlV)bnIe zv{7va6(S~e+^Zr6^#Y3E=1COQ+G2pxJ8m17$}&iY&r>P6ZwP!3#)GnpbDhFqo^x>A0Y#R&$VW%EYzs1n zTQ(mu2nLt(iwo4{WIh}<42?TjN=C*-)X&&mBc>ULLlUo+8egHf;NCG7KyfIk#*5`m;buO_$J)H(5<=00j$Ef$3~ELwXAsf4TuN7QR3a%ZpDp zCooWxIxh%RDT2*4Or73WqcUH^%(*6ro3*Gj{viTB8m&{hyEU0EU%l$IS*G5=E8{tbrK(DqQ9-~tV82QJBR54xbQMZ0Ii=dP;C{SF6859Pd`@SkC5#Mj7q>jKEf%^USYXWS>`0FS=*ZJO# zmV?@<#l6UP)^MjhU}S8e!?PuIc|LXD)N?}8Q)VM|Zy4EDb--eM9q?=Jwj|x~XtV>j zml40!h~R@~pzII}u~QzUy*xM_tsUK*3(X_zzM`b?+$f3|ll2V4$|dKS-dWg-RcmAc zA>MS<@WsGD{1@>r1`6U=YGQ&pzh%@Ha42VKSS&1V;6^L8;Cg^|%O|mJ<$#Vfn`J?} z6lmuozSj;{1vkvhrF>`_DtVxYCc6%T-QT-c&tANUC*w7|i(CT9UstwnO>=YCwkn=I zCQJYG#u>p-;4gLM8DP}|YneJqyjw*nO@Vyxj$6WLxSdsRNNLm^4_lJ2!$ zHEkm?Y&v|UXqa#E zH}$64rS>SBk2a64bKouDK^D5T)RKv6oH7qRM!X?V!brE}TpNyPj?JF}nVqv^$j(z|g+@@WpHe8(W+khaX4+iK z=Rfl;{{338Q2)N6|B2Bc|MBg8Lk2|SZrpA)tZYg+cI=b*3AlLePyPMgua#eO^s=em zGIALP1c#Q>LAO*tjO)PtzFI??`!A};|IZ)Cb_cUU{5{WwP?(k}sLH}Bl=g7Xkbe$m z+g_ywS5l*vgnh3s81H2tRz6s`x%i5oZsEnP(g1ab-AVCWk6#-`U#e;DvB zX(9UD*tncP4@mP0P;9NfIl=aMlJi(q+UXY2N;iswFb|QqtOiIjHWGJu+Wz#+xZvTlW`jHQ?K4op?>TVe?-K12l+hs63R> z_HOA_+t2n#WVE3=GCD2!F$P zMQNf3D+xDt7Y9!4tZD1rP8blh9)1bxdfXHP%NXYa^hBr{iF|4U%Sw@ssDl=481PS);k>j zxa_@@QjPp1)z8Nemn*3@5SD4Y^GcL(jWP_VdMth9L<;wa@6YuNlh%pdE$!Mf0Bpw^JjgaIc#;E zZNw%(RvGx~9jB;(bzj(;2K^9jM-3%R?C~6(f|sTduQ-aFT^}55 zJlugE+gB~Ig|BL7YR>4+*Bp49za}u+jq*5C?9s89J=$r2%+*x7=p=$Fj7U}6MK1ZD zCq3_eIEFSB@juU{bkU+S;MXRd;rIH(*oo2YJ(6yl4#vsl(Ko313*3h%za90krOVu8 zS`|SB#Ky%P-1XjW#M4PT!F;dFm&{n|!6$aGzE``(Kj6BUEM%+Qx}ZbHw;U;)I^SVr zdNnskJ&@_KEn4$?@$yha;q{mkCyas~rS4)!^!hsIi!*v`F72#l2ruVkSL+=HVd*g< z(V=Obvzq(GO43ETHfK8vlrn;XgEcVfX;wsCY0yw9;nUH_m&-Y(`A~A`RC(}JO=3cF=JC08)Mt(@zFl=;bk2xn*1Z(C zwn_4!x%J^S1n?to+S>O?w5eO^~iHT{du%eg}hs1=tF5pqpU|Z ztiribLX%Z+xCsb!SLf+};^yObW9elOeK|FJAe&30ugMi>Vrm&5KIF0TI|Ght+6z9c zL=!`h$6+lFyw(`q7n^Ht>1ZPA{SqFBL~e$Lgl%m8doV8DG3wvjs(kRBC6Y~J zfy5^uEAjXKR(<+rLUGWeJX`wyB5UjIOdqNEzE0+gG@A!taxtvfKy3^?DwlRWvo*KX z^LSvBZWu^p2BO89wuv4qEBSfLS%5*^3DTT(5`ia9G^NT)vx7(VX>8*~QZ3s1i)rWfXyWGa^@zz&aR#e?~O`q)&8xAC$50jEx}xNr1!eZ zarwxP^#p^oRcTvx^na~7V38z<8>~fernbL1IHTf3JmeVIs*Nj(YdPef?eAL2SEteS zA)Th8Z$JwrH4r<4|Jl023Hmr$hUs%PGoHFs9C~tp`?RE6+twB8SzxvXKd+O zQfD0K)^T{;KDfOLeMceF$XqI}1Hn-nUzE#X18vSp7<~ zNSZh(599vukIqipTm;>6=f*B-iTcV(1J_!G|SzO)5KjC(e%Q0hj5!9dh)!o z!M{B~I#&6+R~!#ty)xL`EpfjL3=f|^#NDJGegeVjS2`8D4E8UatLaSSuJrLaxZ0%y z5(7YqLIMKh4yq_yW~zrk+3YX98mAW7mUn)0_BmGOm`Bf2y?yW{Pkw&>SziT+u4a!B zSE?TA$5qaOs%hfmPexo_SD!PZSX9DrAz#G#u+)D3`(-NE{U6K@cQx*~0CQrp3!pSO zTLDE1=Dhy;bkz9~1V6;Y_)7zZ3R?4%FGcFc?4n>~&-?OHJeSj$$2;|FC!LOcqh(WR z#%iD1Gb=;YrE&Lk1y@O${Q0+iZ)>UwCuDE73a#ha=ha9_(}h>$f_AN<<*8Zh_azv) zmK0?9$SEnA5U{+^yV5{6Vn;~A1IOPV25A!HbY(s(iQp3a{zfYtf^lko4v$UNKUo6CN1?$Yl= zRp;vlxg6G}K%&pY0lA?{3AdK}GsNtraedYe_{h_pcr3zxU|M;VMhz0+H|kNspIkH= z9}=BD+^^lDrrb#b1*d3534g(-?4V-*1iRWl5x|;}*EJmN^psOSO8WEw>aAK11p&#{ z|5Vh+A!@Z{X109nxNSm@Rpl-k?xc=*h{66?J$fQ-ZA(Kz5#CDKmNQzWAkib3;$$a_ z90SB| z?yxoDlncUia=X2)3DA?m zG2w9BI(oa938PL5Y5=y-2dD~88Ko*|N8sJC@hc{YEju?Q%ePDg%$@LTG^1gX(CMyQ z1wK(iemqd{2?(Tggx(L}svKJWG;uyagXOqY;+y!NV7X0B6z%uqSx;HpN9X8Lg@#+3 zQ?zGVebqr5%0$sJkX|G?(OZ2(zOk`^fKi9+o1S0s!CJX82GVe z3-1kNjVkARES_5~2M8a8|EmOTfH#dT4mw|j9!jx3oDIu`5ap>3uv*otv47%rv{l7~ zPzGWB>$<_g9Mr+Zb02;bXYB)qY{O4cEAf>7;_09wsWf zEO#FFuF((LxN>Se5LIJ%Nf=OIAc)Wnm5%!9d4&XixK3WEcU?zy1yLx(=2NsAe0vAI#dpC17vX#fkve zyNqW!5u3_|C8AiABiu{a0#6%9$0ExM^H6c9W0WV+6qTl!#)gK-X2{Kj?{N<`TWd9c z0y0=h$?9%ov7S%~7%+BqIZzP}4>+MYY`QNDB_=k{OCfzb_z>)xZ&fxblz;l%j|>+% z|NK&E#Mym(5vkO-@81Lb4f*v>Uk`$pF-^9V#LCJmSXb}f`3j~v_0M;)$W5U{h=n)>@^XDfjB9(cXze$kwSZ>#CsH`Q!qeY6+o7n!=V zy3x6-(rcPwjtw22^GR>d{F5sQa3QRMw59O=mpq`gI-cOPg+Uh-#d#Ap)KE^~62PA7 zq+mv)SX(sD2FQ5JDb5??gllYi838vqewL_FK;gv{zRW4-Eo{3`7Sh$*f(ubJw%MyE zl$=iFe*&*jOU$lS91BE;Z9vPpBlm@QT*95?T}mJ#)75)J)7; z-9icG3UJIF%zX>0R>|~b-WL&EH|75EaPz&k5-X~6 zlU%FZbr*`s+amT{_XJF)(ZZxut>(A#$kw!NOqN?!+1RMMjN&**PP zaFYjB!VU|b*DCSLWJig$W~^hU$Sgf@t(7xqYJ{&A@2#SUFabFVRS9Scxz+FWB<|DH<_kt%_U>R}29q8YFOUog_>lPI>QB@s#z z)b!-}t_L)!R0by=<>@C6v=36N43D*`xdfDw_y(cnIP;|WbbET}l@^z09dVg!)ml{g zJH8rJK6R+k&ZZZ(pZGLM0n<9=l*QP$wq&SqE0iK#xWr1oYC$}?SW8>`3d2~s-f|i(A70h6 z?~?D6vm_%vdmjP=uaiTVP-7LdcgV}^F>3lh_JxFmv@HaLN^N34$#nt>ffx6H40E;W zR)c!f%*Ps9J{vNy*9!=!bUirqtJXm6GLVOceGd>2uH_!^g>9|O^noYSpz_5(RUNo$ z9c0ftas(HEY;}W=Sl)-;G*}X{vTvU9TwVhKb7x}h4I^8$M@d#}JWrNN#$#%f@TgM3 z&Wsef2IG({O!JRKcQ3=qwce4TgNb{uZfR;7+feuwO2@ZP6=5VH_u|DzfB1T=zx}N7 zbwN^cYQL;sfgBI{9n`G2>o*sb8iqvfrvu>& z)~y!v#*VRwZ>gweK9dr691hmIs9e@pCCLU$C9I(p78;Y5n7igF*ivkIp8dP>W2W<8 zj6CcJE?I1~(Sb|dzz1pfW=F(k&!A0Pi71T9p?U4g(PFBX-JEO5;O!pBRe2)*&h-LS zdgv3v`+C*>45!mc<5_#_ubn`b3((?9ehruoa?ePvUZKh?wW}U{*-1-j1fu7-0Yl1}ov+_tK|W zz3XIG26CKw;ka>k#~oMKo!6LgDQ`3-pf~Kj?EfJ&$`*@=Hq!cpg5ybougdmDS#{Ux;Li zZM_GA3#?kR)qA0H$YOA;yiL3fg=*OkoCdK^WNIR29NB+$LEu$Gqh%f+6$Aqj6C3l< z@+WL}?x2K)1v?>yV=3@pyt1fJro&TLDA9VYfwJ2Mr=IyN&9TJ@E*Ln-&;F)QxL9@V zcaK$|rGSH$eDTyW0lCosELGntW4!V5QE*y#*syNDAaDI#SU;{gAW zvC30v39yvQ35@2W@Xb>r<+OBs2<}lxxz?f0~ltWC+3!V)$wUi)FSmU=Gy;I)u z97cMI>7y>|nm+cak-fqD`}@%Q4L+&|cbG@;`(J+{xD0#bYc)=wQEcsam9hFZu@3He zjqKr{Nurn@1bBj7qZ^WwfKI_Xfv@yYb=(e8 zgTOW_oZdCF6gI;Fife;%=dEh6nx4f#Eiybj!a8kDFX|=4x)cO+Gj!kVVaP+5CNf{8 zT!{aj>`{Z)F4o?O9SQDoVQ1j1HWt#+p>567bk(!S8}OO#*p zu9HsNNzwDh_RiL+5ek?bQ`1vBN;rw9mFUW^BD-Y2t7fP!-tMU=6A(;ykw=T^3HcQ* z($U@SNdp1~*pQh0T*t3ry~|9#$H658nRb9%lT+#>dBgfpTl9 zYI5@?)6Gt>+**hSw?dZ=B8*oq?Shl#K$iOR*u2C1u3jh#?QG|+lSWwA zP?WXinS;HJb~fMLyJ=2aQmwDe|1vZzT^l0}@&9>oB{Q;hXKCQCmesU8GUsQudHyE* zmFI_16aM}YY$}MvCnH^lCHo?790=-DdW9{AeWT`0!;%~W;C1c*5oa`}l&1KuUPaL% zOTGTV#e21VCLQvmq~Nf2%KY$Qe(T$F4))L*C?uBuZ9%70j=I6A{-dYiiykITBNwI4 zZa`F2#B!NExXs7+kKtQXu6DKIw~u12NN!Vm1!FL8Nx-(SfT_%M_jEcqk$4-F*L8OT z)Tv4>c|;`dd<+#Ex_RL#giME?6&_4C@d?s#QcKfrSwODz$u9c*PI9{B{1;dZhesLSbP7(XMXKJs-ij~UL& zX4r4w2m^YiNtseRmfr0`(@sLi(=%q+3eUY7|Ct|2J5b7_mo&v<%41{w&(&O0%y}b~ z4BJ4lK z6Zq!7%ZYo|shbtq+c~ppRoR9uU%GTv1)~bAb8yolC(l+7|726>lUnYd;Jo)ET-Yzx z;ySn9vSS6D8KQ)1;vO@}>KGt-5@56K(YH1GyKhNh`Ja~9tGph}7KVZ(<9YFy)LBA0 zQ}3eVXT!;PPJOLnv$y^coGM)PU3)NCtux=io@k(#$@DR?l`q^t`0{4+ix24n=emBG z;|_tP(AdZbqQtkiir(Acny%auT3hc{q2kcb)-QexU2m^&T48=ZU-uG|h_D+0e8Yqe z6&#`Y9Odus9#B_rt>b3sn)vX>RksXkR_kLG@lz6VLB|=BH^5U7^8%0Bn>l$NxOb^A zS~F#R1$*-Ss1IYdcJ*IeT&?wgDHOBL0!0AAj?#LMd5@i>Uk;I}nByu>fAGF-JJJ&G zjHzA#2w26OJ%-P1te6&y0OEg^fzsuRf+}WS?(0A1_ht3~L%3IpsCA>LcAg#pd%nRd z9PrMdWl2{=Q-l%?X``2~(Tn9zl{y`!L(q@nGj6DYG;*+GHOqCoHyqV7H7ZBat7&qh z!;Bwp3*Fm?4TLZK_1rU0#Ui1P-pSVMrkk|u4%?dNy zB+==MC}ACPt%4VaMOvhy)z-`|4N%n=0ck~@Ha8$7*bBoAq!sZ%h9n@Z9DQ$FU-$pE z6(A)cA-3s;KoChQn?m;%4i)g{oR0Jl2-UJ*guifYX4`?+%53~4s88%FKgMvqF7bC< z{5kUNn}o#nmW}A9+u?W!Hz2U7F7o`S!*6h#UEITWoBv$UI5{IqIOa_(n9QDo4xBn6 zye2?^gQK&T5WG3mIgkd%tFXF`7w6B5A1CCQOehyaVh*kEnAp5(y?S-Z`^A00j}j~*dQy!oo5(K01E(r* zJt=CB)fIp7=s)vk#(NNF)0}^-qg>|eCVsk)<<7pTu*`3vIe3C+C`s%Q?mRtcrsb~= zNfg-{njglzehnok%q3u6F+CM=8UjQz_yvF;@PU(`2Ye-RbKtuxWiu#Iy}XwV!zdWB zO8;CcUG3o%Is|GFA3OWe>gcV(E1j)FnLEpUUk%bP;Fw^Iqbv2KUmefU2_=Ftya@Qe z@w0n_1^dJ#PEty8wlmgqb1ymKpAIOV<&O>9VLS-q&j?SpSAXPLs%FPDJnC!;yjAvU zmI2QFCGTrLU-m81^+HQtE!lekQGlO#PM)FqInZ;H$_ySzg;S$!BuoU}!(t4}upj&J z2yvZ)Vr#AIyYCqtLOI?;5uUX4oILgR8J%o(ehAZ(p8V}S##kV`l{Y;PEgW0ii+23Iu zyUeQT>T2fm+h%ELFeiL9QkDcT)Yw5ANHb}I?m}bx5)ZGxx^3m0o9bma_DiXc&gXYp|i%$Hn6 z-41mnA~G1lMK}79Jwv0ZCJwEU2e}BJ<}XEqbfd+6i&JV}AeAtDObqHq-Spnr_;?^S z#zOL0e7vcxUF*bDLIc@jsN$U0E!4n&kPh`2c4H$UG3egWA+vyN z7TjZF)f!D}?1!1JZlu4+DgVkiA^HCdAqF~h;xDN7iT?unEAe0dgIM#goSq4Bi8h^& z*9UDxXB0?Ce$oT&=T6z1U;kena~17Doe`2Phz z=>P6~bY_-<6Nu_3x9S4uI)d11%`%RsU}AdfC|+&f8@soCz?QyX6Dpe52^7tg)AgWaDsZG3%v?=i-pJHKCGi3pv*yzVTlF z$*d-KU;o5!FOgs_!a;izi(21rFPGzw1{kM)vKL3AQ;O`B0#}B z4)Lr-!*X6ag!$TI%@aAaT&8a2;_B8+d1T~y@89aoQ-O(d0<&c)K#N7es^jR#2|oL1 z<)AGp@d9A(*b?jap-BEJYaIM_z+CrEc);#cISG{YS<573afOcGP2br_wul_CU#M)J z)go1IM?ZWx+q^R#A^23O41zn;-I9fTXF2r`72;|>$-LDjrVCf~we-@1*p^PPOydF- za}6!Y3yI>^**KoB#g@2GN({g6JKw9cKaaPpEP*{;`J&U) z{N;FWT0X9Jwe*}%`ZyS9|T>XEDN5E<`9f znfo}9WFrh8^G~=M+oD)Az1_Fpp?a+5KT{x-ftXGeiLSjvA^Nn$ZZ(b0 z#@J{(TGvr%e1@9z$>T;iJ3IF6cou0$uyU!&LAS@`sB%YZ=6{0O!V)C;-Iu#dYsp3q zj?A7bTk_CJw}rLXlH)Yafa*qZTw?(MG^pKL{FAoVwdBeV5IO$x7KWG zlff&_`=qBBVyv$&5tPm)7TWfQPMfm=p>cfS%zG-}{1Ymgzx+V+4;CD#go_UHcYpXX zyrr>!jVLK%iDY{^@UZc42@Nb|%A53|y7c-dqfG#l%iD{tJw54r$~vFIn3vN?L#&)kY0C?j=wuA{4?8_En9urX<2_yF?-tw zxSG|r2NmV9!wHKNW#YF$}^T@NFr9u)WP zmFr1txA>O!7!DU4g+PA*8Xlp|9V$C?zD$o(On-^@p(^r|xcLb>ix4 zEPgDJ{`PetkCi_44%)tKEy0b! zwsn+>1IG)=l-T;$G-$l07>3h>9pt>Ur(>(0%h#2ftGVWh;CZw7#eh0zI6E_B6Iw)cOx7|(Q*ReCVn@Yrua z0TyQ12rp5x48-=E^pjI7LsfQqo(Ju2ZPoZOgFDq0v{;&F1fm=*2=sME}ob(*(1gfs%hmR_P z-@gZif-ejW=FH4yTW^R@I$KW`;HWqVqUGG>jD9W8SFlxF)yZ%DW8&iA9#Ow+aPd5h z`|?XmiwlZwkkhiY4dxcN>VJnJ&axv-kW{vpxq1WiT)F{jiF<)?ske^&r+SI8n@daRf9Moe9+fX08UnlV`k` ztJw0VIU-EdGVN;Zed#HN)1e{Jiy`l&-;StJ;2MrIf+h3qmXmPRnpo!|mA7n19aJsQy4wB+_{JRvyR-C#OX^&&4LZ90N6IBY3Mf67~n9O|eznLSM>MI&vzynPcvbrLdRJ(39l$mbW z5`48yghL-NDPOQvY-cfE6Wl$GVnEgB@S~;P<)ZC z9&d6!XfDx!5&Q`_5FpSNab8++>KdhJzEV`2|L}-kM1ZkRe4-e1P;csalWMo&BS5F) z#IsM$Umnl|$oKh+eyx>zs@|K%-3;Bpa^JlzGEj-CBrD^DNH`UQa;8M+OxnzTSo0T+ zG?VTRv$9EoE*8@RqF_Y?RD|o2q~O&v>pQ;+Kn?ttE+5TtTJoQ*+vkpqz`cjLG; zWcV5^c@J6ZHLB~n{L=AF`VvHQ+rXrwBOjZ=;QKnf+2|J;Jxm|uZ*#l$6dgQ~QcBvG zemN`T3^T{zH7#cEuD7dGQz>2fmIGF5IrQlte0(5%r8q}_580Ea4lX=+@on-@WMmuD z@I@53mVtql@7WU1>#R+eF1`-=nPN8D$pEi<=e*9kSbRx4m#9n!o~K4 z;3BuKGS^M9hEs?&wnkP=MO)xIY+66QfS@M%0`z*iTDnx~dAUJ|)2&DpG`JI4YAfzN ziE7*`@#nF4wyYy?^9g8GcFJ6xb}F)4^H%9b`6xpn3frH^c8L&z(*W^>fC}9XUIbyB zUbU=X%?uH*5s$E_IMP3#AnKh8sbau70+kqa?H)7lEFd$i9kP?-oOA}WM&o7*oGjG@ z9s9DukrkZ6Fks&rDu}6Yesxff5;&+d^i>i+xw&z}+_Hv_Wc?DrZ>AjuS}NxEK?G~? z`N(a1cGQJ#yT+5#AnEqu@97$a+CCtPfc%g$+XIGVzg@n9&1SK#9PIi{paL&>{9_D2 zT@ENS^267N!d9y~tiwc%V!B*>Q;oe(53V8qR%^n;CtFQU;1o_pxpVhvN&BoU6;&uJ zWg8q-8l_DJ;jgRQDDEQwQ~{2ub}I5&x;6;j_T7mWKmJ+T;%1rc&dLFs8f(jxBJqWnoxi2h<-{6KGN%DVS>K z+DvL*ae_qqE;@pfP@Y(TFF6h@&pBlN9WA9BWtpNE^N;SA0r?U z&0>CgF)V9cEm#t-;J9sLS?cd!UA?+RRkK{WvNrNr@{JJKI;+vU;>vA~nq7#*G5qfy zbYq`P_aX*!wXz#Pw5qIJXcr%n$aVOZ@FL*HKKU^%|JOykjgf!uFarY?(i?IjMu$gW zwvlGme;JhJhs^&p15+bT=i8a1)8RbRJ>UD4L8=QsjbCP}FE|mfpN-QEF8UO`)W)Z4 z1prS3qro!ow48*)yl$cJy<5{Q#4hqheOi&&91Nd`3PUWTbKg6Qw*(i32Mco$ktC7g znDK=gOZV=mkUqd)HCNedH0Lc)F#$)SR9fnB{|#yny(;@B5+fzZ2;Ib7C-x5t-fNdU z=n^2t=aHw-9xdO--p=UKU{2h`#f`Q>5$-YpK&*hh`W^Q>^eO9Ja_Tuc1@?*l?dXH* zo-RQMkN|+2)L|tqn#qUL80iSvPNtrn_#n2B2beDrF|eatm3ZsreQ+3iV!&n z0{5XEkYG%FqMa?ZN?`q@6tdgu&lR~;1Q^ysU6Y>e^WkaC4_7lM196b8Ewh>9MDltH zIM4{?9LEi=*n!F`m_-Xg!gYc|%N+4hZ_%>jLWkDRHoY*1hA$2oV z5nT<3AYP@0HYay$qC1?5^O&X|Lw@77{YlTx*RTBTo#)}|s(K1%O@V0#BO;c&;$H99 zcD?Oo&>ljj(-@nnURF3j$(jtAl|?p&M>jY8LXvKg4S|qPI>8LnZ^MZI7bfYJlbIiJ zT58qG$3p$G(d?VYedWF`De=_u44I5pK%v4``_s|VVtN;gw1Z@K2FIsqf_yp2UY~}6 z-gy__&ti9Q2bzh)z#E~s=&sHvCY7nnqkFgGK#F$G2j*EJXc4MVUBQCgg`T;T_~tH^ z=DTZCmJ);4?)RkSS5CTBI`4cqePy}#7r6|6;aR?J3#T&_vD$js%EM5m(TO`b_kG+O zi>2Hb(p=OXFV`#B)AamqOdiU+TnbF@C>AZ%@*v9>_)chKAUmb^i;Uhe=Wj5RpbUjU z8Ib)p0qB4~vsA~_84#%rFXTIrlvmh@qL#2@Cx$^Xw6Is!y0aq zlkh&8;tY%HXEX_W6FM-yYq?CLaD7^R47IKq`&dwEZ3P0Bh;i4MJv#;+Pmkk#9Za7} zNzn<-rA1(Zu0?HFgv_G(z90`EdZNT;e5k#zZ$BwMKHeGM*E2m;3{%a!T9eE40k9LT zDz5=m1`LK+nBcJH%Q0{O4W1;m^fw*c?a62hNnXaPQy zrbDN5BJLzhq<96G5RCaPo>_wDQR_5Zvx-T(>s3x&fVC5wIjHP(CE$Wm^A2gs4n%mZ z%nbj`TZc+!cyt&PQ3)arSkUgzniIYy`;*ZK$}Cpw>Ng3$2#^L()cLL>F(%CAYnRl5 z6aP^@b7T<9ZPn1sj?J_6Lo4(@3_&3h1NEHT!y;A>ibd< zDEupmY=E1ltCw(@svWgc5j~h)2{g_*r3=JQ&mt$c1^Qsdy(Ktgp$j_|)45JwzUF2* zY~wwiLbT+DJi`hJV&n)FOU&T6ZzkV4dn2MjjDC&Yi6V644q;SN$ITrl>Ne*w&`up5 zZ^&jIoRx292RNP3y`lM*t#dx0xOq5+aZ!&HwY(g1Jkp`ly7(zE`zr1n$^I=E<5GoI zl@~A{WQIQf7TFAN#@nZ-Ls^E2O;=CK-&7lS83M3g69G;Mr@MRe@O4L~&Q8?mQQSy# zkS?_EcAVXSZpC>;G@Se?ykj}QPAx~trkg1-IVDL7jr-m52|&}}nQhLuK>l;&SnJ+x0q27_bAoyh zqzCb(7!1xV9ThkR_4MGyY*i&70IUo@u>q{K-TSn>Uv-X+gOArA@{_YP^DvUEcZ=AJ zQ-+F^hhH0#2}W2i)K4>mG2HK0$3X$l4-GQx1Bp1#pug(HvTcquJ(5Jw0;V~N>9l-# zeqO4DH2thc70zuSFtX%debAGC8|u(~Zz>uZuk{}wd5-3#=}57A5~y*_gN_|x(L1y( z2Ul^i#SUjv^d^cj@5LgL+~RnTZ1O|dzH4K8R}z6<#Gcht$PcJTO#1Hu`V5FZwS$ia zlY9Hi z4S8nG+ZxXH^i6aG=^$WPLAjGXk?{y40nNQ&xb(jDf6oBd!T)||u;f~Ha$4hso8c|{ z2FBi|xrqh!FpXO4vH^5ycP{MQ=CSW7FyJ?_cgj#Nzm0PsqPLvzvK$6mBKA{({`U$U z?>#@LCOten2=E7C9Lv~8#dP5{XKS!@fqMi-$=N!X56U^Z3pFVGB%HvlL+M_*zrhy% zn8M-W>W(}1Y%BpKC9*hfpo4aMsi$dfys{p~L#jp3Y9!fC1niI6KRpkJqMum*u~N1& z)_|V3T2>PVFZ@;mVW1Xw?S2EMRg<*8kuWsEnE%|(KUV9OloZH!1^kdCCYKsJ0l0P97fHc zvQwUdnitMdva6(=&xFF4u^7t!y1AbQ%Ij(|}GeRR%; zAKQX*PTuMADAXvp6h_`xTdAh<8Xw~n*>uZP&e)u7*}z&=Z<2{P_jrOKCsa|C#0~-k z2r8#YDGi9++XgLq@$zmbuLHcqrs)T^kmIxZXj|b@SOws_(8sC7Wov*umaGbuSq(m#W@FLGd|BS( zUHm(WED?0?mS6h$GBu`gCvzU(jajr)UW2zsOA@2&IK9A>ADqX^-$_qJoU-2Yz3)Bw z$5W_w-P38Q{Xh_0^As#?T;J0YH&Ly_%(qxba(VBsnBE3SagmozSvBrOa@ue>eKGh1 z@nLbr%a&GFrlzKbNF$>!{l4#f*i_Us?&S&ivSjq=jT1J=`Zpcjk>DG zEl|5h0$hSh$CExlP+pBy%n!-zdHN}?2s=KOkh;342L*eus2{a@XH3181f08GNwrW) zB_p_uY?T@}RL_tw+{(*!doR6~Amdn@DO}I*kidq<`A8JPIJHyhQE4E6{A}MZVIv7W)GCPgQ^qD zeYF0bSbkmij#&y9)HRfAjVy}?X=F&3d106>-@;euV zHe_kmq=1=d<~44cwcFys3_|wLlu8S!l*-&-tiord$p+y-*~;IS9a|3`L7N(p;~5Zj znv=2&_CGvUn(>^MNIa@4idl8EaXgZjW z7bh`va`Jxdx_%@j?icv;#*v(7c56{--9Q z>Wteqyz});(Nhu>O|D!2Yce`PF>%@X!;O~cME)}*>y&!6J0Ev{Ct1JG zBlX}7+t9C}>Ay}(i47p}*S`*Y0lxnAhyVIBa1Q+X!+-sm|I48E|NJvhGZX*MfB%{P z4x`J{&&JgkgWb3HWiq3*H%2Sq)qvjfN^^gr+ZYsv^PIW|J%hy2<%xII zCK-%EK7m>u@;(9jb9e=jy*X0H1K>NbyIs*idu?K?d-EpAsW_Cq90t|ow6y$2(&#;P zum6Zvf$cqTQt?xomlIzJwdAX@0*9l8dX}*u5R>XvPNu43go`Y!HT6Hg$^bSf3P?sT zHgdVs0+kNz6k;Eqri>1MK8xbbG-%5k8{F&P9wsGDJU|!tO)k+{+aojMS>|SMiT>^ETN)5pk5K05i*W*%2&+k4gXqA*aO0Nik~=VE5Q;Y` ze)3f5gYV+)Zkb8_wn(}Cttim>n$2EYPi|IX08VF%niV|d z>8QjOtCySKo>R6)%C=nFTNvqVZ+F}o>4+~~`v60NpXnIooZdyVPQBYSG-ft7d($p= zWClTT>il13YTo9@=eXL^;2u|A8|_-JahmrAiMv~zASKGxE0o10CGPluNWgh20tI3^ zsjJIqS0W=->@Km`GPv`!B&+elZjX4iS0fu_yp@YofwHRKlApOesS(A8iAMM>wI?E;qB4nX=!5h^~I56EM^Qv$z##2odrV+;m6v8nq*{1Cvc6N`0 zT)6hFP%hr-Pqkp?T!v5Q1!|62vFv*dcpvVqfd@q8m2$50&cJ;dCalExT{?LM1(2-n zQq+(^aLz-I!-b7-HA_BteSFiEmT=*V!~!P_B#?&ZM7=iUO;jVKk}Z0_Y+QWW3mwPm zi9^r^5pyo4Ev5*i4sWJn{}@g78@&6!mxe}0vNU<*+^Rqw!9J)aakc)ul)S>);C20;>Gfw z;|4RImwS!J@eQbZO#S*g>Y@6M%07Go!)-djXEK8bu1`vXa?!c>wQDLsfN<@a#dc(_ zZnYq?;l^<Z~Zzk30ySp4{ApTucEKLgo{;SKC|061q-I}^S8X)s zT7k47{$y+ii)e zM<93KXGnwL9d%zWs-B4VJD2cLG+m*F!+t|t9=*2}BFjTb9;BYUZPNS8)dUD{n_=bU z2kQ^Rb8y>yy5)P>l}_R}X?_?o9YDqQE&3cu&E;8m8cZK*C;NtF5%4 zrMmzay^qJ&qY=e;Nb2VtXFG+s_4U|n;RNpKk0WY zCtq_Nf)7o(26Gz`M;Og2MdItmW1T1uL{W$=3 z=*nHlhrNPN93MF>~iOHBW^%J_oiFKUb%gUrC3!D;aYjz0v+Qj7OC0isnYo zkl+xrn@*q_|3G!D>vBD@*7o~qpkMS}V#E0@TI^>!${jPfDj=q2Zf*_|Ww{G3fWGds zsT_!Ft?`fucXjCEHHN`OM|jwfqW!)3D%4I#@%MTi!BJ-w-YHx!$ia%m?`?yJSE-5Q z8QW8Oxby?%*x2 z@~r%td057KbRhoOM>KJCjJ5X7#7k(w1HkfCrjr6%8Lj!mt| zhx6zEww3-0^3y@ZY+NPU38@A&LSPc>gTg<}<7*+vGk)Em#}&L07S$xgw@(T?ly0<^ z$<7TcQV!j0f0fKJ#Jc%Q!(|hc#R#NEz9Pyh$7q}Z0sYnwyHYUU*jwzD@&0fJ!OCF*E-OIb;5GTX3p8O66PT%9+}iqy7S!IY znlF!0F8L+yr*+0m0aP;aO(#yz)+deAK|vngpn$Hl6@2hOI#roBOCy}@H)7c~IOM%B zlyJVSIp}u8i<0~B#ek+i%|rA&=}aPQZkDVu2!)&8vrvlRX>_eSQ3ax_Xf<90wfk5C zylOUFJ}VkLa!XOB$U8GJTHt{=PN z!~qvZSxXzmc*}nVUh1$UO+Rtzj^8$KK?!ih*G5o3K<^$DkNqXJA3%dne0D^E++w)s z3I#D+;mGKARb@x|ZKskY5i;?eDE0ysB^=_kNT9>|n=WfF{3|GQN zrBaBZ47T_h$$nNar0&eE_&k3?SY!^AX9w#;VCFa8K|le<_){J6VgTXtGI8A6}60FkCk8y z$FzNrER-;Th-H}kz64{9!3d{(MP<+)&f-2-fMT-AhTMB)tIxGAN2Ahxif8nb8#DQE zsi&3fE;C5cGBYWGQ>P8~PDH;JY^XI7`Ncbn(F)iG6R+6$_|(&TEsAOSaa5(eG&%NA zZ_O<_p$4i_;i{wwnyY;HBn)E)Ls(e*7KL7dUOnU3PHPZsG->ESod*yk)4ihw9Bgulj*4`oz~Z7 zK+*$>VUM~INaO*J0QcVI-7PB4=wmJwmZKP*Z#tzfw`DkP(8hAh$2RM9f0c}e3Xo|l zow+Qc8#VD2kf*d$0e$MN3nc5aaAok@wPpb+2%MA7;-X_Q8rgr`xLsxP9b_-7O0YXg zn+I7VTSdJmM>E}?sHmt+^LBK!3zb-Tf$ul$hjsE_tDYBT_TW2qJv3@HoGz{sJQNx) z?mef6>E{s`RY6?18?J&40jvF&iWjm=k9l91-q=t{@AC2S0X(5;ppy^2T`&a!eN@za zmB(Q;k{*5gEV6z@dtBHvIuhVE%w^pAE+UJSkw;$8x!KX;;4VoWKNGu%(GgkilP8TU ztFO%EVBgcNN2UvYjl>R<_t)-jBDZ#q1$tCD6|j{ zmOD7=f%eUn?uN(yLIw=!LD~>)a@HKgL}h177;zVMBtWuBMiw&TaE7?O=|MylBkZ7T zpW7K%x(z;a&LyW3!?z*(qH{*O>gDID@0Tg>tJbx)a-)Pu1!thGPnWrH|+-dOm*EFE~KDpLG@rlY>L2XBe}q%YB)L51V0jLru=2 zR&dYYEXlsJ1XN?(8|lzd!@@e+AO9gz?vR4qAb(4!+V*tn-5MZMF+1imM^0Vqmn5Zn z=KH5ioh!o%6bb9y#7R$3J+1}hYK`;l@;!uz=!pc7B8+mz*M+=V z^(6Rg#=h!xCJZ+|<=)f_~qBw3&VaaLj=Sb6GV%r24o1v*)!@u6=xDc(jCTC-ama^2S)>)fX7sRmkPOsYV82nY3<~i z+1fcf+k?6ik98@VRE8&RDod$BfAL(6v#^5~06HP`;vj5+7H1Kgs+Sf>9X6JboZRSQ z?8{@R>(ak;m0!f>czf$NInsa+l(NzHA=l{h5y&CC0aYgth0hDW@6n4~C(o8jHt;z4 zqSiVaT7O)zC3@zy%~Jwjhi8%q9K;?61x#1lgm-R5xOz3i6pxvI)@gt}wuY9QxXX^+ zT@GG@!_LX3i+HEJu72L%`eJtg(U>^xlPN8h@~y3rMkWYMW8+jv+9J{@#oW<9_%BRWe&-cv7=YgUJequV_C zdbU|p#HD3EE7a!9!6>-AJmlu?h+|TzR9EQe=ostL(*-BQ#;0Py*)zn#1_qx+6I(lX z{F$3SqRp{g;sA!(E(d#phD_9ByKgc%UBU!vS74;9f*FLNsi{wXzTFm8u4;t?+0r1~ zu<{;CuyllW`jB^n<&19Q!Q|=g??VS$b2NQjLn*!ReaNlR?4yXfn@8_~mST|Z4im1( zbg*Grg>A+xVjCpYkxm8$s7G&WB+KNiXS(utF8Ibe7M6@s(yFTyeaf|7ym$sS0ETYE zJxo&9JiAf`KzEZJ$E#cI>;Um;AOwyZ^9AAmN8M;VOj@mn3X+WudcaYaQzPwN>@Tbv z1fJ7RuhR785(Go!tk>w_Ful6m59z5I8LF*Ls30j7fX@&3FUGQ)N zF-r>AzIu=bJwGtIV*8}-E_k~VA=^Ll#F^dS{=#AZ9aa5T(D#2Juf$h?U;p|u|E~hW za0w9K$bUb=MEoz9d%2YUe*tqxa7M-dg1Hl`-J}2ag1PHX6M^`3jechu*r{Uum%&_g zBx7(Lia6!U7a?biL8?O!H+PE$c+reH(~5X@_JE?eF9*EKb5wFC!_zojH$Z@Mn?&sW zqC*sj%XwO&^$%-Ub*kNx2Sh0%P9Io#f%FGn7I?7thbp%Ij?Z}ueN%_d8R8zLfkYB2 z4ue{d5&-QU#U>68O`za`#9@B@Ro}r?6d65H@RB~R7DB24CPiDQ+1!4la^(v4Z8Nw@ z0E>I4$YS;2=b1ktZ{z=={-+`y1dpDLE^tzohK-&x9$J32&*@Px7|?YFzWO8v;4;ba|26MX$NqNhYMxo=Gg0=gJz zUHqk^ckjbcao-n4@EG-S-S7*=&a1)&MmtU50B3l%!n?*Q8)l4y;w4~lxI);{0MA{e zniFw8Yl8y&ab_*Gbmxu>+e_P&A+uoNcG6hOuW!DqG!4eqEC`t5z2u1loMY3I2)Sb$ zzYBT-b!A5>B*|8(U9jvPlW^cyx%*J z^*>5Jn5QY0%Dx#2ejiGe27iPCmyWSKySwp_$3~U8CHmTJK`@_0s~kE)tkYb;84~n6 zXgY+ea@8@w(w~^KLM12Tg8vWh-UBMiY+DyaZM6;HMnn`uv_udP5G6+gNl-v?E|r{f zrYKvKAOZpcl9eb~a;A*roT&l|$vHz&gsMBs?!C|1@9s0sIrok?-n-X!40`mS3ID&= znsa^g3+MYXK=lEPxgAkp9KsSxbRV>Eih2-NB<-R9!8%Z+o9*173J572uXqkKMoP&c z_QPv4ji5hziO;5@&Fn5#badIW*>C+r3`3a-(Kbe2nSb{LwF%A%>&L-siRTnA*=8K% zJ8Tsf30KgJb;G{_&j$|f_$=n4q7u!EuP+Q`*FP&TEL&px_-3^1*_EpPR}ITdrSJKW2Cdz&_yYwTM{TcNQxj?CE*Idzy+>?tewuv)6mp zSe_)CfguS08Y5KzCv4&yOkJ^G}*e!Z_zbcB}6WiSEwyb$yqK?HC}j zqJ%tekIA&Ow$?5cT%*uKtVun_?VAsa5sq7X^tIn85LW%Zhm}Buqj9fP4Y@-uqq{;l z11wB>W}QLh0ys95Ojcp^&BD8oIK)zaiard6>@S;r%F2qEJ9ZQF#-HE23qDEMEDvAE z7-TQYQd*4VI0b440L&{5vg;Ih=Z)CgIgP@H(KAJa&wR9v5nQ{kS>)y!_B=?h!18Tt z4qN)DhS9s6YDMGb7SJ7x#>ZZrZ9^8Tkdkk$aZUc^kRMz#46{*uU{3cGi|AZmirRb~ zwPm_JotP`jX$bFdVk$)@ML{Z5OpHZ%a5_aPagIjq>7Q~R?AzA{Ym=ce38V<~K1tru~YxwiM$?6PX!U5C!{$ecf1%PD&gcxY|tVhc8YQ3kbCqc&uRv0Dvvriin zf@Z3+!GE6eK-~IEn@HfaGV?gRUMdK4@n7>$FRfd1AEc0&ygcQD@f3UWX_LaT27MEm<>oIuMFV7HS^mBF=Vo6=WG3GI;N`C_F=dtw7wIUr2RiNws_oy-$z0f8UI*&$mo5kynDckIw=w+L zk9x+|&P>d$Gan3SsJW2U-uKS`C^pGBfLH^!ku~R`RGl5sKV>j5`Vg_-R)6r|wW>P= zp3~V*>)#UQP23$ny4RJjjq&IQsuK`wX8Vhbdg6q@?pm{{J-%wANecXl4ubIriAYdP zyD0#X;AMo-Rd^Oao)EH9um4mnn-UT^U+OWt+w|$i7qs_%s!Jk(Z-6#I5|YEFM>^Uh z{DB4@9P9$op_21{G|VpZ5EB4SWK!0vpFXXCj4E%q_zDQevsr>_?}*!P_duEr=!Np% zLOT6z>AAj~r)E}8+XJ(Z7hvLE1k}JLO}mluhrIiR>`Gn%@&ZSs4oTAbWF91qKli=o zAG*uIIoBHSMdAcQOExd;l!U$EPn5dILgzO?~3SWwB$GqM%*Ced-lc`>-_d>G~?QMPsHxN*6%wl=PI&UJoGnS@qdv zBQtbk`CYj*?aqWv91)}oTM1}Dk_QloeNX4@XE8NYqnHevAiw#lG#jHTPpCtAA?_p8xZXbyc7C~z1>jUea6Vr5?0+Y zk}+j+fGMv)%G>Kk2nN>#fKAcK^Abej2|OU0rIxxpKCF zc_sgs7zNRuh_N2Dcor1YUFw2PIG3wa*1K}h@%Ytqq5@xh*xsc#bD%1Tx5%OvR`{&d zEWD6%yl5H3#=yyE+!|is*}K+gGue+90yosD_PA`>H5V|jeWjbCv_`|ch<7_^Rf1*Z zGUs0&`Z@5))`d2v=J8~KnpwWrNTRSQ7;*+b!HkW*Q32TsCj>BKx!l`tUgkcu@k`piKmb_{Aqf$U}PAF`i$y?CggM&%v@N7rYEDt&(^?5 zOY^!Gg~1D|H@G^2HwYx$-+=uJS4DgUf%0!nAGsVHRuRejUbnlAD6Wd&J4)W-!vS08 zneu&@RxE3L27VO~-4eviG_AO}k}}%ml-_MY01F)TIXY!7t(CbjttiusyeBsdfnOq^ z+KTPv%!*){&atYGVkwHi@Fa!_Kc0u+@&fDGr+!xrutHLvUHi~caP^Bvc3MT?gy4Ei z9;!e}!|`D19WO}r`KY;enI|NG*FB8goEQxHV3eMtE&LEqEo_)EFY-7C=8qrttFE3) z*qu2>SWa)@6B003Z7wEE37N=$f=Macpz?M%!~%9bPqTt828x*cuqM6oB+N?9PY*rm z*C)4DuZI5U81*rb;4w6)vCY0|?Nwj;P{z2sR07epvb=f+JSC*12?PXS-}DX9Ri2TK z-i?%Jf`++>hkm)|)?Oqhy`r0K|Iql?-aM3?j2wnpAhj$zsC${IjJ?bn7@$E6JU5(3 zYs(pJRkJQ;-`^+73if!dxKDmZTqSAqv4c|N*l{---2#u@5eQA77;+w`5h<{{=V)dC z1G8SvNWI1Ni~!+u#kkAF-Ii&d9RX;2O-qi${dKaLiYnWrR3GLv;4z+961&`KX=uHh{HUIQ}+;b#Lryur$zeAL)vEM*1io~F3HmB!SI{?%<90%JpmV~ zvPr^AXn!HRW3Ys54%VCyhhx?+_!34+;!Zym@mlK)+vVbM&XMc~?F1wcR=i$rztKprIlek%kKJp!aM}-#kv=Fu%S*d>BU_xy z%IC0%nyQkAKSOgt6U9qmD7Y#m@%&O{ZH!3EkxN~J0tRq6kk?iDG%vxLVg0xcxq6d- z0Oh)F*_M)~bqP@jq}i)n{eca8F?g<&YFTMnm~EInu}$pk9c*~ck@O&2CZe)w$e>Zg zXKo0X`8yfVL*?lS@ZMoIX(_^RYFC;zZfNZ=59QB)fp{m;((4-4pK71O@VT6JAG|Y3 zyM8u|C9zHisoe8nZP^VJ;^be#E=sq2X;`+*Eb0nyxI7FGePT<5K{XAhAzW^VkUuB> z#C(h?_uH>KEGq(N)r?P_#pRh5V2=~Tc&|8KRbUA1wCKqD5j`64= zYo}tq`~Z8IIlpOck}Rd@6Pgol>47V!G^>*=gUQ?eiXfliLKJehFW4(BC&(q-@aW?SaweDmQW%n86u%_w7x> zPzO{e+YWmjnh8hKjR)bn^5aK6@2<;!JW_KIJ22yQ7+*tf*xiyKGTwVm&lN8fTw|bO z-%$IFUG4NIp}h({=`~P9!0m*U3mC|>VQ}$wiF11;Pc~!*uN;+3?@So~^XQX+{Ny)3 zFYdlE0lhw~ap+y*G0qa4bJRsn6zA6EN1*L&TO`FC=el{*60Z7CBDiCF_Mer&j7fRl zqtm5b55`n`D57=RjgqneE(k7E$&o(*zLz6jXK2sIuxq(qiLQrJ^XHW#&)%BjlAn+b z6HZ4# zB6tvsx&pTSSp{JR8dFqEV%5l6agps&s?jT%0+wpstOq}BEq{SIf=xI*KcmaoR{({$ z)UHi8Ce*++BX~|zS))>uKM&b$pSJ*Hw0reKZyif?_kV=QEGoP@E)R~E!%X0A#I z2?;HZWTrs|D$LFw7Jh|R7o&9m=gO>--Ft7xO-%s`qK!Ug3NDY+JbRmgu(zLq zP((@f9+igBJ_{n8oMV5RjBvR#3XOQR}tV zLIh$2Kq!Qos*U=(MUV>452ihA=dqF8E*Iy~hm$~|X`Wr^Vf4rsW-FLV62DSypL+K% z)k)iK5vy$hk=$a`+s~-Mf}Qi16D8%PQF_zxAICZRz3|OVU4?q}ns6fR2!{T^Vhcer z3f^m5Ib{8vrNOjT7?lH~Lei&mmv) zP7*jDf9dK)bnPs=?&LdV{kK^4j!;X@6y2A|ep|5j@d7^@SCO<`s2e0CFeDGEFm#9xrJ+lx6 z_iq|tsi>C0IOZpKB?U}0My}w^Cckdt-f53pl{-(S2b`nnt1$w_BM|F3)e0Lh`}@So z`Y~7vgLC)UC#ox)9xw_SH@s{7@Z@Lke1Fa(aI?N83X$e_uCR0Oy4+*aiUt|ps>D%H zbCat;hAcrbe+2+}&RI0AV`QXMKl7;x=)53fcJ;?UBr&R|%^@D~_oj;*Mvu_504F)YC|Z-fc2`Gw0y;}e4BzkFjyKnkkr zc!YJ3e6(w8%yA~F+H>HptH2ep9m1-`zz)|SsEjwjSBaIp2|gbc?vqo1YRaT~Mq*U; zwY9h5x+s%s@ePYyBTYayao%wGH*NJ1C*jrC;o$dhHTp3KY5OW{it-(=b2#iS`2tYw z@AonqvWj~;MvfW6Owhh6Dp=5V_|}uPcmROGJ0;ZX2Su5IUTg@w1#(|H+61L&&^&^q zNj*=66FH(kCrxb(@2|)el?!_9oa+z3?6Da8=99fi)*P_Nu!Gohg9>0K>GO?^)5!~b zUH=;*7obmr&&KJTzW#P(g-_yV@QuNHjZ)96k9Qw^#yi#I!b|HQZEXG3F+{5AH!IMfR=**2Sy*a?}|G47wJ4mB6 zs#icd8*3gRyvQ<;v3>%tmC+WTRAjFKadv*u2&FmAgw@NDp0W90g2 zAn`=Ztf_%0H!0)waKZG-2_y#Q7vJgmEla>A8XjJ;?N<9I=6}=JpgU)oc3kc7*@`&j z^1H#Z!?pDt@GjSCM^r)0IX9eZ0JnxGzMiZxc{0)|oe%bXzC$o}p>fmgN&w+uHN&%i z%M;eMWY|1ShN^!a0zVm8@`L)pKo8P#GghqM!v*p|_tg z>%#+vde2}IM|JKC{hxvo$pd+hGk*CG(*3jl;BS9-;{)jY*HigF^l|LrDU{=Xr7y8$ zxFBqO7ec?lRhN}$X>Dy?Ci!iOR46Ve$Wvk!5*$1)Vc!EhAFwN34LtN2^K}i`@476u z7kw22=j{kJW%Nou2qSDD`j>le`X%JH!Xys;Q!mA5UPUnt%Pf;ITA)0%fc!n-n2?$`zt-Q*k3|D5CZ%x0w1ojEEM3YT1tuX)2!2UsF6UQRy zI`;xMXq$ioM*UT-T!?Q0`DkD>Q|XNLwtB86Wn6x&5z&=w;d@H>CHCLNJL3OFwY>!h z;#D@bIxxuvk6BDY4Yu558nVs6KM&j+x?tiiX%1^`6#oUN_{M?BI~nZC2@eagA5z$5 zQlD0I!SD=B$8$wDl$Lr!h9k=RYW~!N1jqwZ0GNf;$Vz9TuDrQ6P{svf(Rq)odMs(- z;=Y_yf0g#Un1gOOj#2cJ79{wAol1j)5b$}x6*hRTe*%VQ_}KqUr2-MyI{wrPX=u-V zKP`86X-5xtP?u!)+bTIq_!F%8UsoN2)SEu=@7EABHCykmnep8voD;VG3VAZ8)rDub z+UyQgk@UF{Or;CpKGLQX;-`f@bkH{u<39VY?XquB1SoTxp3t!D8inIF38!*!tn~fI zX%_VDEG@r6Bp)dlST#{YrwRk9A^62+qK z)}+xv7)+8T(Iboxe`SWSNO~rxYI1QYCMZEH3IL+OO%v4r?%poM#nrr;awJe5N-Qi- zEEc=9L{bW%K1R(%*e?csNpFrOM}yZhv@n{BQ z)JI_byE(bekXH^gGDyWyjJ_TfRrKX&*?-tO>YCRt6&w^q+3{dx16*?}6(JXbzY~#>s`e!0 z@(kR*Amzb!lT#t3v)H0@+c(7m7>2IoiaPfMFoeMdV*?vrH;zM0R`byVqfYx=^?Fo$ z(_kp^bTCUw9ZVd<4n^{FAr9?0mlT!g6nj&EpC^+L-^V$g$(IBu5+Oi-tE^2!(X`#`f@t#n}r!WblCd9AZ~E|XFe zh!rQI!FU~#V3yyt-9hT4slsEQA&a?Be23o%SnrTxYNI_;uF>!LP;7gr=H~SWD=f&< zRNHId%vhSRE0d}lxdwa8k9Qcot!;q(+k~dPiaSUM-8165Klv>9In}N>^OK8u%^fmuM-&l5-*F*1{lL z`E;=u#~J2*LCBN2^JEy?3v3einTEin1e|$ErlgIe<5+PQ*5~UW-!JNjYZJc#iF5!4 zv8~bUS|Jy7fZ7JxXnaq)THwE#wnv$Oh+|&JM%i%?WFyRC4(aXhJK-jqtC?0E=Flcu zY(wG`s{mqU;Gxoy*Bs-D=Ue#M5Q}<360@#bJJk8}Le;R~K~xovD}zI3dB{mcdWW#| z^w)4i=<9#3Q_8W^3wKu7!de zALn(UK_O zB-@Wf;|J1==HFTun{FyhP1V_cuCia9<04ai3kRX7qLyvEII3^)wOma24~Eu#i?L8C z(96YxQygG(aL3kQ4oxGQ>R(~fTcncJ2i;&n%7=%0hzc>4b5;I`!*r;?j0B2=S_ zs(G*qSFTlv#htMcBPcFig#53YvXc0~YetcQ(y+ zSj)cRKn|qxmxCVD`+G*mxd;o`kxp~7g<}z>=Z-80SP9t;)(VM=Vj;kDfc8+fM&^68 z#m~ZCKuMd7AiYX}YihS<4PWX+hV*6tSj?o9hsp+ywcFl*2*?%n}?W zF5idfaYsUVPqM{6Q0J}qm-pUa99mB&%z#+e5JLk~v9k$z{m&jzO0nuCWFp9uVMEJE zYw{%4Sog93$^YbWn!)Yqwy!VO&L3Iut?FYM^aO1uP@>^eB1Iy&ei5v5K=Essp}5pr zADVQMWj7@tNB5?f4VM3Y-=l22)UY)Owji&}4(J?7yPk`Fx$y0P!cEEARM=9>)zr#T zF@>}rxsVE%#R1WmS5ON>C>3c4mW~|ZV@?vFoT|M4q;YZ3x$+N<3#N>FGQvW@Lc4zZ zTrSXZp)ht!wZ7&1*7T$3bkCzYaW!qz;)b_;zH8;k_BJ`C_@#l;iYNKvii$x$eRqqk zhMm7nC0GVyx0#ozp4lZq9$=VOE@|l|$X!h+dCcJCZi7cX9vgVVQ8DiMXLzKocKGAe zcF5A0vwN7Ym9f)s85Nw9>-eLvYU|>f*Ccv8-3>BvN2)gAJcFznHECTzo6kpoiOs4e zVb3W>JwSvv_L}@i)MtkV5?^J$ z_wmbe&1-8rvpDy;e7HTp)PPNkF2YUeS;B~0SOvr{!7hRm3fxTHr`DVxsi}RzS*mCtx=V#FTbqwfh(V_u3VAT#z^K$W(75DyD1y!ctR{fZ&8pQ3rrQnA61z?J9rsef z6L|QUq(N|gDfsEb+Eq}|&VGV}fg`Z?W3U(R0pzYq>;lg&2eEO!RP?FwNHBZ_nYga> zN!-(4*DKn86R>z))ynuU2v`mu`p*O``tbg&1`2iVU8%}yIUs1=LB>`smtPB!3>n98 zJ$rm_p@wkK_4@mR$Dc0OL=PyQu40S&Zr=0#JL}eIuVuvV+A4qZI+i@}<%p{E9a;{; zowb$8cs3g5YlU_hL^Cj>mkpKBSWh5$Q zF;>v??0I7SD=D{Sr-BBf#KzT~+gMx2dp26Lu|bBUxZCQhhbcSpPbrnn z$p{j-n_R~0mBBOatJB1`+od5uHFI<@8oRog>YCS(=}{V+x|+Rkx|tgrZ!~`u!D_6o z9!soOdexvc?l;+xI83g~Rg*MRJi{yM)AOc`qyVj?L5Zys-q2uYb8f9|Q`GI}+QfOM zZbQkmdgs}NkxE^$mQz%3jwL4}mG-y%8F-UhoTg)i%wj7%mdb3qGEvz9W?`U-)`Krn z>wUI$(VF_~x+H2TPUJ*a?}d{y-Q1fK`rU=P51TD5EL~e4coAlLs^r*}_$a5qc}~Ne z2IY0L^kUa9?TQA&p_6T!cAt6lDvbljWUO>Fz*?kIV>1hzp~~*oG!2d;^RMWe($`)` ze3J_?sGaLiGl~OcWZFK%MLt&rg=|f31NBN{hc*r>?>WPoS26tiRS^ ze5P@axR%cKIOFCKT+erDLZuRS^EWS{G!uWDs*Qfh^la@;MCO@Wf`Vh;4pGHiVw>7b zJ&!*+RBdEz%x|^RPtTQVr`@4tRO^0vZ-W;zk8jginNMC0KlQOR>yd_7z1iu^&@Usk z^Hdo}U{^m2kO$K2WOjc@BABH@wVh91(-S)B!N`g5SbLYT8qCia+|PkWM&|{-_O|W* zSnE5CS8JGPuw_G(Ef1YI>8V{P&1dtw)U1=NG^l73@+wmuPL|&NAg{#=dMasiss_h= zWW5q`>nM3%2pGFLv&jBT6N^s+>(ji&yx~#{x>Y{|9MqosCMoT^j@U_|?GnZMcgasz zkFCZt|58}QCZRur*MzijXMVE)W3cN7k0pHerD| zxy$s7XMM&dz{?}i0giU;0D5IcCS*iTbyCCX1MRSZLb{Rf87~6-w zCSR{4+6-4H$E9(fL>-P7(`sABB=GNK&niE^*Jm>z$V1(YPQ9Db`QpWkMB!O3ga|q2 ztucr-VZAGwWc%_gH6}^SPNHN9?u)FIZWcnuw|ONg=;7!3`SR${eh#F~^n2;mH`}wo|>$jv?-m*Nc-FXK)kB=pR1rE_Px&5{SrnmuY=LZV~JN0Vqu(y8R zVq#eM>r_icWm#fbcjnN#JK!Fi_dn;UX70`W%#?7h1SDkw;=YR}BK2uqdpimQLQ>*CK_TdcwmFbS_XKo$%Tj-PEv z;!ToKh|a(D0$;+!S<=+dAfKkr$ixJ870S6&O>}S4>pnhdJ$~WU+ZFQfE95bWSqq4NhwT^jC6M%#8ltc#>y-S3&B$N&FgQS`t3kSUD%Z$8?^ z&X^$oFB;0vlXP(|*4-}l;gk3_18bXp2lC}m{@Ncvy~$k^t@~arST4vTS-bm;PwKiZ zBTLeo5u~J~e$2HDlkM@>O*f#E$)h(p`0F7RZhGO*u0by zgL*N-^RDv1>0?PK{~;Ky8@2i|>ug%>Ce}DhtvqSyRw>bq9I-i7={ZvAL>_v{b@uF( zH&|+|n={1;i2Zr}TaKnE?^nd@)z@5P;^yasdJVPA-?DiKN+ z8^rkpNXN!VW>x+s;eBC^|AF)c96>R&MG$7OI9z85`*ULPVezw=j5z>41<2hsSX|v)$;eYi~UA^V5U#R zZpPgoX&8T3Mhacp6$)rbigRPP7 zpj}nEI_h9$`A=Doeq?#lXM1QAHz)ljwFqzI;`$JiCTSxnuW{&y?L|XKxUs=CU5*^y zn(e%^JGK%=M*#{ei+e)~x5SrH5+WA1hCloIOTb`&cyrzKVUNxB_!#}Ei<0NNFkW#{ zThJwJTVoHSytb+Whvk!}Pp#qad*vL>z<`2+Fz6X7U)IUWBAyzj_93@d zdt}@UPx{D-+jcEjYH)(dFk4kjOxdyr#9r9mhqE~ROcjdPesm=euBjD{lcV=2e*XpV3Uf)i0nk zV{w%VySiL!fvd&j4SzID8(aBOV`PXKfp}0?BK(|5et!MMIl7n7BWN;N_1f#;U}rK- zB)! zUyJ;N4!lFwY~$o)WC)D>w%__BpoP&fWH9h`-);y5zsUNUfN+J%~_Gi-?=vD*0~ig$z_3 zJjOOve`8=lf9{3reTjMO-r1$sSwVeK2}f;*s*9H2)Hi%Ujr{!Vd4W*j#A`m=6x^^d0SYy&5;}&h-1eB5y_-!B6DJWjZCVcO|~+s&6RSg z9q+|9<~hDBR5RuI-E-T0C{=_js->5jpwMG}Bs3oEbNOt)KVvQWI_FnPIa$TvHQZJq$t_UGgm{^SgJS2bhxvQ*V>Jp+Em*)%EO@$eTy! ze5pS_D>PBa(<}X)ci-3H$`Q~DJ}UDjNNSOp$~d*I<3tD5N1#jz*s2I^TMUFx&Z`Zs z*XDA0y4vVLg0}cnh0Mgjht^s-x7~}w39JS|0We~|6^Du2+aq3o#TvJ*KAnNrtgmMm z=C@L>vAo*GOy#Y+_&i|fl(+kA7A95n%(X+4EivCpA4$*-uD3s6v2YvxlmSV%4Musx z_oDh`kbzHSL`z*>1$r>A@#k32o~EAbJsrjtoXZu(J8o>3&Aq<(3k}!6#_}=H6K99)*k^cGPnGY_7rMZh_*O>9Eq6YTy3|J~f1eUBj1}Na zdLn||$W2kft(J=B#ClYSCy_q~>D6l|_28Z4vd)AP?{d+BvgN6Et1#Yt8Nd#}O}8m* z_<{7-D+q?`hIw&0lNRRg`FF-K=X#2qs-hbGwUb29I@01H9_$`jrM0&Ea!mS5wP;`0 zWJjvorllu#;GCcfgU1&)(9<4YS?p>#ePo{n&gLq8KJkJF^#XeERZ zT{`u|V;uO`DhEw_5SH#9N^NSu%Z zqAM3a^Y#_gUuc732;GHu53l!^W~CdHKV6J!#St(e!4S8)QWjA!SZ0;Og)wi;Vj#DC zjjb0aY)M7`=KsQW*yC|hklce7t1Bg*_GDY|iHMazH^Q+Sghm$mtSCs4f8Y*3aBg=<5yZY=D@W=g!LePAl z+-CwAFFvnNE{Blr7l>t>>?G0D&fCaIM&J?G(iN$eWe{_3-55e$nzs%tq z#KpGCw~0rcZSjSky$XMvx)@CIG=;9qRU=YiGD0~h_RSmn-euX4g+cZZCQ*~A=e!x- zLg+2y`ARtBW**N^qDsA{1-auO+KaRvNrv?d-g4&d((ctu0ZpS--^XN@Ps)`xifIVJkYO-o5zN~WiKzd2fNJrs z!s2sDf>B0ta&qKxnccqDTe&6f;e=1B~U8C4dz01nqOqH3U_xvh9&P2E3wrIoB1^ccX zA&^j$e#g3}Le6($6uqXye+1d;Z#spqVMW@D6=LbRb@rAw#7y1MTBq_4)QCaQ*FCLE zRWXBa^C1swaJ9X)N?qY9>LAs2D(>+9(<%bALN%3|G9uvlp-^w@R{tQ7hn0 zmgg&r6Tk0&2DYsseb|x#HdUQ1doH=erTz~P#KF)ol$5-@vU-a&RNP!4)6E*43HfYX zs^z2E@7`tm^b?sVBb1x%fDeGctw#66Szh?<@+@>*`g$A;c0v@IOHrQA^70S#OFg@j zky?QIs)_Abk=mQPBokWTggp}7{!Tb6GJ@IcD3zqI>m6yRx6|nHDB~Y*9}8HiHyS1G z>P)P|03hS)W4n<8q%^0-H7kvpSFjSb8E8F72+Q%(8nAViUyhB^uwm?zn02ykT$7r9 zKvvZP67hQgII+dW=(EJV79tlPU%rPZrSE>M4OA|Na4AL#CWW7P5QVngc{9=ZbNvNbYDJX+uNkrZIKuPY zPPGKA$ZRdG@aWiO#6z1L;Bq+5ZFtz>Pe2h^jXnr%>AAUUAK%v19T63sL$m2T%I-aK z;-mVJi~Vl%ZK%bRn_QaL)Vd2Tnb@nGH9D@h#iPX;8KcR-T$N7EBJSB$WTi)EG~R<=w;p_bbZd6OYspbf z&&v03VJQzRYEU-jsB_kboO^GeS}5IF+C~GQKhdIbPAZyL{o0wZFy5^S_lc4l00w5M zH$Igj*P8bGX#L6O?n$ob9x+?zSxksH=}#9nf51&TRprYb<<=uOzhfmvI8T&ZE(eu} zl&0;4p?ut^&+v!`d#xF7aGW^%;-s4AgXoa?M3<=wr&Wi8x2xV*EI!Y!4}ABoANIIG z_Ed}xLxQM4wpBi%R>SpH(Se$(lQYAU<2i~U7fcgyWj(BxO>cJyT?F{DOGDHb_=-ba zkC)L~m3{wSz0_Y6p_|L8TmCrMG7>y-T^Dy+vm&`x*bl*T_tx$|WE&)(wq|lN@1aSl z_Tv1YLogMbG@WDUOIeM$}1o48?lqv3~ zy*&Jxuvhda-BM4soy}fu+kX#_%&>Z3ljeX}B;P>#-~;FU`QDN>$Kj|bW^CDPF6x06 zRBcrb(-j|a&=^`Wkk(F>yG+*xI5)iF_;RyP&M^A(d%0Uvd#Rn~b)k%@alI|IIw`$O zrDT`II=@^m)$`iFrsF6X8O+I(utDgX*_lt|*4$oYw4`Pc1Vgzb4^&h3%KAc;rSB-z^;>!y!6z5 zF+T;^-PxRJpa8qwHVjY|piK^t-|geRxlWqEYuOHbGEugC1UXd`%5TM|Ttw4;!vPpk zJ1k~=-=J%(u2l>o*HE(OoI8BW~*{Ry9NUx=X0to$6S1rvcwCikB3F~3-3GlslH^T0m z{rr+1`deQ}tMEgrmD$o0M~6e8aPz5V!BKE@?Q|TF`1%!57kbh726UeaX*)}C`S8?pj8_qyd31j?U(`8j|8*8luD|Br9|*N>M(E8u^L z+nZ;2;?lpwPPH|8zTKgsGXC%jpZ>oJJAKx!^SM>*O5MZNHOUmr4PjPiYzr~5UfIE$ z>e8amjtlWgS;`SD@KeExxh?jYX@^fbF0H0LST{0WvqZ{&MX{N}A&&rLH z=`amNhDaVA@kN7p_`jaew(o4rynTcTgmDuKnlz5udLvOaNMfFs%=+fF^mVdXE1x5> z30&6Zc^1EdBpsbwR_?q!PCt6<)F_<^ZlE$UV*j#My9-lkjXSNtS`dk4six0nsP@Ua z(k->_MW*PuSkvZ}eIU8G^;Wi?FU*FnkotTF*NJ(@$+;3<_I8Du7Q!udo&IcYM1S7V zkLsLpew*9k6q87n(jcQJPi%E+6cntC@YNNx_IfZ(&e!EgyE!{2L@`zkrAMcy8ApfM z_)#yd(-;&C3=ANBklYB@hfMU`s@EFXkSFJyNMpT@CW;pPj4$u5Cdw_p$<9bZKZC*F z{(Jv>M>cx!Pv9ny6qVz?ikQQJuS!%4noKzO?D_|lYjDtHX=W3copCkoldW+=;}8ZS z@K_z-ZG)IkE5Cb9=QWlYY&5UQV6OMqmgZ^A@5V%^d8B|Z_M(CARx2a=FThsZdxMpq zUte0(ndPl&40D{O_5|N_HwE^q6kf*t$kfAS+1g~k9ogs{9|iy0cd@3s7NnMwIY9th z!?jUOcXv$2d80Xcc?hLb(bn3)#I@DNGWw4JJ8a_3#8(t4ExwYGER3fg2DFrnfLRaGQJ4 z$&m}4Qg=ToHsQbS1XGJkDklx-@)> zkFiy9)HBC%J?b0hQ zieKX3&`UQC?~}$+p(I=fj_#Bs{pJtPMT4}PFzcV)d^EDrB``D;U$8qG99L%ckgQwv z9oQL840-y*W(Qib|rSSPw_f7=I@PS15 z(J?VKpK*lvtrD188nJt)VEWbv?zbDE@fitu9zVKpD35*7E!^^1Sy{>Uk7d7?gQvp) z$BrT>G96d#xMYmKcldmGLTiT_X?#5>HD_36wJ2=*{St`>fF-zvr?>BPerZduNLW0I zN;$-HkujLfEM(!KHIsLT(3-fb<8L)aDi4osT|A53Z$pB7 ze1JwaHSe~p^)f}}kG$#n@?bKAkUJke)gG-S4S=_cP>fWN_eHvOthgJ`ty^lekeRSs z)G#dzLxs~UEcu=0U_q}+OfqA!jLz=me0evfz~$((vbmmG3Y|5A_tZ&GVe1xFTAi)= zJ=S}#NJRW*07?BV)UX7M`{dpaE*P4hld_a=ix)YmWS9_j{?KI#I}yK z?!OBtw*LpT3|>8OZ}w{Fbpiv=W&6jO@9uqbIOEr{`|dZfCY>&I{ITX$n%7r5Qf|_ZrN&t^>CK-itM%et*$=f` zTMB~G78FgoDJU2#F+N{KcwSxIPT7qQVpJQ;TpTvxNhxv1rWHF4_a6Z6AO6$MvcJ0( zOLs3uS!f?zfZG>dWIFK}lC1VVUgl*E8UcS#~|#R<`cA<5cKV-prqWG1@$(1w^>{-pA;>Gl+Gn*$dA2?kR8_6WOj zV(qIyzV-wzyAkoqmog`qYRwm1<{rTO4;=8*zQOjw3s`n8Of6Q~F4Z5|THK;RqAFR$ zI1nH*yrz8#4Jc@a61U>KX9=p^aG+xPjcO)9*KJv{y+AwIAs!v7MCCu24L4DUJ`)BD zl=e|J$tQo@P!e0|J?B&kRr487Tm8Tfi%DY`?7rVEaPRzT$E}vG9_S`^eTNW?@tqis z*5qc++a{JdI>H2PbE)PMZGnZcjs3~exLpT0Mpje&j8|ez6YZ&zI5MVMIiq;R)^}y# z4aSHYE#;q>o~h%p;E#QTR03w+Ilf|KIPdvTFJenf85G~`=)+DUv3Q#D3$(P&j2hG- zbZy#Di+K$yB-e;R;Ls{iav#q<1T#+k^-~?Cek}J4HmF&;2;kEnSGw31cBLx7d45P! z+I3Na6`|A`XW7ik%K9ommLJOkfQMGaQ)02Q%JDxBNkxVXg|xLq9-Pc~!otg%qxzd# zI-2=i75C<6qIrYpx%YNl7Gj*D=lXL!8hX{p$4di>t)B?(#vPl@ZT6B(=kg%Pz!4cI ztchva#Um1@%v;UqKAYt^XP#C>CzOp&rvarqOSR&;Es=Cj&i1#VJndTRHQe)9tl);Q z>)J-l39dft3u2D;oMKQcXR`1vv=-TJK^^`Px8$~D`&>f6KfU9JM9qQSm9P{zm_j|4 z{Q6v^{3>U-G>h$Vn%p4RxAVJrYYP&)TUTp(}G;^g9_|R zD5u9*?0)^$joUm*Uk(!W$Ynw*)#gu@yQh7$%O^JHwP|B8GiA%Q32!Ih7yYQ-#7|m- zZz{U8Jg_GjI;&trnB0eKgC8(dCrg&dh$RQVqaH0XHMQJX?BU&v{tYHc*1ev9!En&Y za-G~~7k1bS0L9djlyfR<$IRbE7TwaPrYcJ4983<@Pd$VLRTOcq1aQl4$zC1M&&1U` z&^ovl_DlB78+{C_O&k_o8eGomG;_trU$zmh92UTytg;NztTIk3P<1~G&7c( z9i&#Ner&%g9k66ga)rxa7Ik|1VC}B!Q#esQf_!!@n{pbF!In=WmP8)Gq_E>TYzRc{1 zeg6=}%y%%a1*yKWqUM=vZ`xs3O&kVVlLKJ`jL5lTO|ZsqTr72TC-_3$&w6}c|NcyJ zxU0N(wuk{(L>y<1`}$@VW%2A(D-kU-_?#Tp7luAWy0R&uz(u&Twwz@fUJ+D4@6S9T zwOm^H+$5Anhp!y8CJ7QocUPavNV6g(gU%QE#P5G=e^}H0`WzPK3yD2$$(`ng(lS8} z7Wj3!(4;OwB0K^B4)7VZF?iJd2M;#ZPq}Ze6a-iUn3?o47H!3eKpejcz{RvR7)bWp z4O#Zr66p4Q`j>WX~9Z=Rx z!k^YFxy#9506@!4xNwsKT6d*C+D9E79+Id5SwxmRPD0*`Q1wba<%kPxKC@4^qk1Os zTYQ49aJxY~1u7j3pyAfDFqnHU{3dV<%#lOo9vQYaT#~&MRK^Ys+BjpM^GNqd;^HJ-T_{qVznETkNNb>FAxwGzecn_xa|%~#>e$bhceHc6W_}QJiV?J2Zd51$a^HO zWc=$7#gt?uR7IWyBZG^$l=q-jbHA*4(iV<O-VFEAB|z9p zzU1=q>s(?w76lp84DndRp@~EoX&P|?GVSY1e1#{ z&xhML-ZGEhEr4TA;3fikm_+xefv6WaGiG{cT4d;oZZwp5ypB!f<-a4}cB8jxt&`vT zlG`}(DD$h9oskITIFAQQtfJdQ+E5 z%n?7OINND$?W-QVF-}$RPONJpe*Tqz4Uj`M9_u$`P;f42VO8C3BNE>x=s{5YYo%PA zt{bPoo<}(5{u)v-c>UA|60{F@<3M&ZXCbIAhU6VM@G)IME?hJwUa}NsSW-Tw%4VAs z)WSKVs+vZ}rkb89?}7#6?RPu92ra0UlmfqwQD0^BOCP!*x*C26Es7J+f~u;Sc|cZ~ z)u#UItI-_$MGk0Y&l>o;h?VC)d+xwgY&8MIlMpaT%P9ITQa@{$YH zhq49QEeNNHtVdqqY0ZxA_mUoan!y#)Ms@CBTC;(R6^Etbq2qkmS2P);8Qk?nr`?aw zuz0L{TJQK6)mAxDYsn1JkbF=}iype5GVEY+4-bd<+f2pss%|J#@Vur3K2Kuq?T~#K zc@8uYj~nw~rTJ87BlH$YD@{rJ^#y$Q1SObcJ2dKCl)B*7jucEMN^8AG&E z_HxVfI^xadm_0WG=f*5ko#d22pSg8s{UE7~j_SPQ0}5Ffn*Y?10tb&X)x1&zH|H)> z>`-z6yWCjvxV-vB+EE~5?h9NZO&KuTRhP2bb48(Yn}S9J`Y<#-dxVvjv;@8R-f)M-fb!IILZ~kca=VdSpqT;82?%D3#U4%2al zySv*`P- zV%SSAH<<|zXl7TMHL%39*35mmmmJD&m-+PGnTFJ6ejQ9lfyzxUKg=rqY%q$4f@2>% zdEr1EB9k$W*`FavQ~-4;++%&x`IMxbu_`0ap4pASV4O=m`-z5&UwQ^U=Q^=53cD1+ zJ+If$(ZpD*Y$cIK0c5UxEL;;r&^WWa+V}6B7ZOJf$R*^=M&TU@IKP-?1KDT(={G}A ztl3tgbz571XgM|ZgTBLVr^~g!HEqho$;-)gHaF!&S+42?8hii>SZnRRLe8Y%N>fGQ zo_ktL9fJW@=0-nqgv1(pgh+y^_CL;$>G{rGSu;9*6s&JmO| zTa85a)lO2Al9iR+vXEyXvVXG|^t-vAzLiWeuiJrj+q48+eA#2z!0&v8S1d zWaOj|pj|MYatqkTMnG@9nx>y^Kb&Rw%AMF#dH0{1PYiKg zVADrm8OcZS2cTTdT%3@H`tJwAunU`d$))Tr#BzWjf(jk@wRWp`l10^?a+9{5r#107_j zBpM(fsA!bBYYEwW|VaEo`XQ^0v3U#D%iR zvFPv|(S&$E29Q!ugs_7~;Es1mZQf|tauwb9{| z#4)Mc2UH~;z+zHey;gX;K<^&W+Ie1N&i5Z{dbfJGk+V)B?cXCoEO(>Yt=m#O-Esw^ ze!I2@Pppx~Zc|oHD~*I6nec?yI}x;@0;9eMl;82~+F6iiak}j23ENW~8d6`b2!Wut zs*Hvh8XkW#{g>Izvecw_mT+chi;~g-RJ+8jA%qbmppV#nSj_h{o_J<#$_8NcGf!E6!0Ji(dKleEMiY z4{!Uv7OYD{)Pjc(L%Uz?=W!z7mhhPp=mRS7zRZ1fLd>D1_ag1nT@%ThOo&Pb-}1+? z`~7%Mxfc*c;Nvg_V8EeEf)pq9`!0^(7C2g7TCyX;LXe%!H>`I>HYVRnA&^}PV?R`A z=jCv#X}+PHJa0~wZhnAoYKxdO zX@ulgAO#JZwrRh6e?#1Jj~gqIrD?~e$LbOuc||QJ!}|AJ*g9h74`$nE2x=iX*uwg* zfl^$0n+fsSJ=1;~ckUhbG?p4ZNk1EG4{G7uhEzRIqPq2BHWTF43860Xme_z(| z{u{~ehphM4isJu3b^IY_My^jE_&K98ZUmzP?R+?@5)u#A<@_OjK3K7N8{y9L<&~C) z_56P%FP2vGXNC2LQv2h5{LO#Tzka;G-xBI~EPpoLVzG?I&C3mD)BydZKbRR?8ReYx GxcMI^%qt!M literal 0 HcmV?d00001 diff --git a/inquiry-agent/agent/channel/outbox/sender.py b/inquiry-agent/agent/channel/outbox/sender.py index 0451ba0..612c772 100644 --- a/inquiry-agent/agent/channel/outbox/sender.py +++ b/inquiry-agent/agent/channel/outbox/sender.py @@ -67,6 +67,21 @@ class WeComAppClient: 副作用:出站 HTTP 调企微;失败返回 ok=False,由 outbox 重试。 """ extra = dict(payload or {}) + if extra.get("msgtype") == "image": + image_result = self.send_image( + touser=touser, + filename=str(extra.get("filename") or "image.png"), + filepath=str(extra.get("filepath") or ""), + file_b64=str(extra.get("file_b64") or extra.get("fileBase64") or ""), + ) + if image_result.ok: + return image_result + logger.warning( + "图片发送失败,回退文本 err=%s:%s", + image_result.errcode, + image_result.errmsg, + ) + return self.send_text(touser=touser, content=content) if extra.get("msgtype") == "file": file_result = self.send_file( touser=touser, @@ -213,7 +228,9 @@ class WeComAppClient: return WeComSendResult(ok=False, errcode=-1, errmsg=f"b64:{exc}") if not raw: return WeComSendResult(ok=False, errcode=-1, errmsg="file_empty") - media_id, up = self._upload_media(filename=filename or "quote.xlsx", content=raw) + media_id, up = self._upload_media( + filename=filename or "quote.xlsx", content=raw, media_type="file" + ) if not media_id: return up return self._post_message( @@ -226,8 +243,64 @@ class WeComAppClient: preview=f"[file]{filename}", ) - def _upload_media(self, *, filename: str, content: bytes) -> tuple[str, WeComSendResult]: - """cgi-bin/media/upload;密钥不写日志。""" + def send_image( + self, + *, + touser: str, + filename: str, + filepath: str = "", + file_b64: str = "", + ) -> WeComSendResult: + """ + 先上传 image 素材再发图片消息,聊天里直接看图。 + + 字节来自本机路径或 Base64。禁止在回调线程调用。 + """ + if not touser: + return WeComSendResult(ok=False, errcode=-1, errmsg="touser 为空") + raw = b"" + if filepath: + try: + from pathlib import Path + + raw = Path(filepath).read_bytes() + except OSError as exc: + return WeComSendResult(ok=False, errcode=-1, errmsg=f"read_file:{exc}") + elif file_b64: + import base64 + + try: + raw = base64.b64decode(file_b64) + except Exception as exc: # noqa: BLE001 + return WeComSendResult(ok=False, errcode=-1, errmsg=f"b64:{exc}") + if not raw: + return WeComSendResult(ok=False, errcode=-1, errmsg="image_empty") + name = filename or "image.png" + media_id, up = self._upload_media( + filename=name, content=raw, media_type="image" + ) + if not media_id: + return up + return self._post_message( + { + "touser": touser, + "msgtype": "image", + "agentid": self._agent_id, + "image": {"media_id": media_id}, + }, + preview=f"[image]{name}", + ) + + def _upload_media( + self, + *, + filename: str, + content: bytes, + media_type: str = "file", + ) -> tuple[str, WeComSendResult]: + """cgi-bin/media/upload;type=file 或 image。密钥不写日志。""" + kind = (media_type or "file").strip() or "file" + mime = "image/png" if kind == "image" else "application/octet-stream" if not self._corp_id or not self._secret: return "", WeComSendResult(ok=False, errcode=-1, errmsg="WECOM_CORP_ID/SECRET 未配置") try: @@ -240,8 +313,8 @@ class WeComAppClient: with httpx.Client(timeout=max(self._timeout, 30.0)) as client: resp = client.post( url, - params={"access_token": token, "type": "file"}, - files={"media": (filename, content, "application/octet-stream")}, + params={"access_token": token, "type": kind}, + files={"media": (filename, content, mime)}, ) data = resp.json() except Exception as exc: # noqa: BLE001 @@ -250,7 +323,7 @@ class WeComAppClient: errcode = int(data.get("errcode") or 0) media_id = str(data.get("media_id") or "") if errcode != 0 or not media_id: - logger.warning("企微上传文件失败 errcode=%s", errcode) + logger.warning("企微上传素材失败 type=%s errcode=%s", kind, errcode) return "", WeComSendResult( ok=False, errcode=errcode, errmsg=str(data.get("errmsg") or "upload_fail"), raw=data ) diff --git a/inquiry-agent/agent/llm/extract_text.py b/inquiry-agent/agent/llm/extract_text.py index 9ee2f47..2937cf9 100644 --- a/inquiry-agent/agent/llm/extract_text.py +++ b/inquiry-agent/agent/llm/extract_text.py @@ -23,12 +23,31 @@ _MODE_WORDS = ( _LABEL_LINE = re.compile( r"^(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|" r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型/数量|车型数量|" - r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(.+)$" + r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|" + r"客户名称|货值|是否为危险品)\s*[::]\s*(.+)$" ) _LABEL_INLINE = re.compile( r"(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|" r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型/数量|车型数量|" - r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(\S+)" + r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|" + r"客户名称|货值|是否为危险品)\s*[::]\s*(\S+)" +) +# 群里协同标签,冒号可省略:「客户名称张三」「货值 10万」。长词优先。 +_COLLAB_LABELS = ( + "是否为危险品", + "商品海关编码", + "车型/数量", + "包装方式", + "客户名称", + "海关编码", + "贸易条款", + "货好时间", + "是否含油", + "是否含电", + "是否含磁", + "HS编码", + "货值", + "HS", ) @@ -64,6 +83,23 @@ def parse_labeled_facts(text: str) -> dict[str, str]: return facts +def _merge_spoken_collab(text: str, facts: dict[str, str]) -> dict[str, str]: + """ + 私聊已经说到的协同项写进事实,进群才能预填。 + + 不覆盖已有询价键。不拿去补问、不挡查价。 + """ + from agent.schema.field_validate import harvest_oral_collab, normalize_facts + + merged = dict(facts or {}) + extra = dict(harvest_oral_collab(text)) + extra.update(normalize_facts(harvest_collab_labels(text))) + for key, val in extra.items(): + if val and not str(merged.get(key) or "").strip(): + merged[key] = val + return merged + + def extract_inquiry_snapshot( text: str, *, @@ -80,11 +116,12 @@ def extract_inquiry_snapshot( 标签行覆盖 B 的同名字段。B 未出现的字段保持空,不本地猜港口。 件数/毛重/体积若原话已带 TMS 单位(件/KGS/CBM),空字段可机读补上。 """ - from agent.schema.field_validate import harvest_oral_measures, normalize_facts + from agent.schema.field_validate import harvest_oral_collab, harvest_oral_measures, normalize_facts if injected_facts is not None: mode = (injected_mode or detect_transport_mode(text) or "").upper() facts = harvest_oral_measures(text, normalize_facts(dict(injected_facts))) + facts = _merge_spoken_collab(text, facts) subtype = str(facts.get("运输类型") or "").strip() return { "business_line": mode, @@ -109,6 +146,7 @@ def extract_inquiry_snapshot( mode = mode or str(b_payload.get("business_line") or "") source = "deepseek_b" facts = harvest_oral_measures(text, normalize_facts(facts)) + facts = _merge_spoken_collab(text, facts) land_subtype = str((b_payload or {}).get("land_subtype") or facts.get("运输类型") or "").strip() if land_subtype and not str(facts.get("运输类型") or "").strip(): facts["运输类型"] = land_subtype @@ -123,37 +161,90 @@ def extract_inquiry_snapshot( } +def harvest_collab_labels(text: str) -> dict[str, str]: + """ + 群里协同标签:有无冒号都收。 + + 「客户名称张三 包装方式编织袋 货值 10万」切成三项。 + 只说「纸箱」没有标签则不收。值取到下一个标签之前。 + """ + raw = text or "" + hits: list[tuple[int, str]] = [] + for label in _COLLAB_LABELS: + start = 0 + while True: + pos = raw.find(label, start) + if pos < 0: + break + hits.append((pos, label)) + start = pos + len(label) + if not hits: + return {} + hits.sort() + # 同一起点只留最长标签,避免 HS 抢 HS编码。 + kept: list[tuple[int, str]] = [] + for pos, label in hits: + if kept and kept[-1][0] == pos and len(label) <= len(kept[-1][1]): + continue + if kept and kept[-1][0] == pos: + kept[-1] = (pos, label) + continue + kept.append((pos, label)) + out: dict[str, str] = {} + for i, (pos, label) in enumerate(kept): + begin = pos + len(label) + end = kept[i + 1][0] if i + 1 < len(kept) else len(raw) + chunk = raw[begin:end] + chunk = re.sub(r"^[\s::,,、]+", "", chunk) + chunk = re.sub(r"[\s,,、]+$", "", chunk) + if chunk: + out[label] = chunk + return out + + def extract_collab_fields( text: str, *, allow_b: bool = False, injected: Optional[dict[str, str]] = None, + allowed_keys: Optional[tuple[str, ...]] = None, ) -> dict[str, str]: """ - 群里协同字段:标签「键:值」+ 口语含油/含电/含磁、货好时间。 + 群里协同字段:标签(冒号可省)+ 口语含油/含电/含磁/危险品、货好时间。 - 闲聊、报价金额不进这里。商品海关编码 / HS 折成 HS编码。 - allow_b 保留给调用方;协同六项不走询价七项的 B 合同。 + allowed_keys 传入则只返回本单该有的项。不传则海运六项 ∪ 陆运协同键,给意图分类。 + 闲聊、只说「纸箱」不进这里。商品海关编码 / HS 折成 HS编码。 """ from agent.schema.field_validate import ( - SEA_COLLAB_FIELDS, + ALL_COLLAB_KEYS, harvest_oral_collab, normalize_facts, ) del allow_b + keys = tuple(allowed_keys) if allowed_keys is not None else ALL_COLLAB_KEYS if injected: return { k: str(v).strip() for k, v in normalize_facts(dict(injected)).items() - if k in SEA_COLLAB_FIELDS and str(v or "").strip() + if k in keys and str(v or "").strip() } labeled = normalize_facts(parse_labeled_facts(text)) + labeled.update(normalize_facts(harvest_collab_labels(text))) oral = harvest_oral_collab(text) + if "是否为危险品" in oral: + if allowed_keys is not None and "是否为危险品" not in keys: + oral.pop("是否为危险品", None) + elif allowed_keys is None: + raw = text or "" + if "普货" in raw and not any( + w in raw for w in ("危险", "非危", "有危", "含危", "是否为危险品") + ): + oral.pop("是否为危险品", None) merged = dict(oral) merged.update({k: v for k, v in labeled.items() if v}) out: dict[str, str] = {} - for key in SEA_COLLAB_FIELDS: + for key in keys: val = (merged.get(key) or "").strip() if val: out[key] = val diff --git a/inquiry-agent/agent/policy/air_group_ops.py b/inquiry-agent/agent/policy/air_group_ops.py index 97cea1f..fb04f40 100644 --- a/inquiry-agent/agent/policy/air_group_ops.py +++ b/inquiry-agent/agent/policy/air_group_ops.py @@ -155,7 +155,25 @@ def send_air_brief_and_sheet( if room and not view.get("collab_chat_id"): view["collab_chat_id"] = room facts = dict(view.get("facts") or {}) + from agent.schema.field_validate import AIR_COLLAB_FIELDS, pick_collab_from_facts + + seeded = pick_collab_from_facts(facts, AIR_COLLAB_FIELDS) collab = dict(view.get("collab_facts") or {}) + if seeded: + changed = False + for k, v in seeded.items(): + if v and not str(collab.get(k) or "").strip(): + collab[k] = v + changed = True + if changed: + view["collab_facts"] = collab + patcher = getattr(getattr(flow, "ledger", None), "patch_collab_facts", None) + if callable(patcher): + patcher( + work_order_no=no, + facts=seeded, + sender_id=str(view.get("sales_wecom_id") or view.get("salesWecomId") or ""), + ) view["collab_facts"] = collab view_q = dict(view.get("quote") or {}) override = dict(quote_override or {}) diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index 92fe889..233f10f 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -17,9 +17,10 @@ from agent.schema.field_validate import ( AIR_COLLAB_FIELDS, AIR_REQUIRED_FIELDS, LAND_DISPLAY, - SEA_COLLAB_DISPLAY, SEA_COLLAB_FIELDS, SEA_REQUIRED_FIELDS, + collab_display_name, + collab_fields_for_ticket, display_name, ) from agent.schema.land_options import option_prompt as _land_option_prompt @@ -101,6 +102,30 @@ _CONTINUE_HINT = ( ASK_TRANSPORT = "请补充运输方式:海运 / 空运 / 陆运。" # 旧一句只问运输类型;销售要一次看到类型、线路、分类。残留调用也走完整清单。 ASK_LAND_SUBTYPE = _land_option_prompt(current={}) + + +def land_sample_image_payload() -> dict[str, Any]: + """ + 填写样例表图片出站。 + + 走企微 image,不走文件气泡。路径随发布包,出站线程再读字节。 + 禁止在回调线程上传。 + """ + from agent.schema.land_options import sample_image_path + + path = sample_image_path() + return { + "msgtype": "image", + "filename": "填写样例.png", + "filepath": str(path), + } + + +def land_sample_image_fallback() -> str: + """图片失败时回文字样例,避免销售什么都看不到。""" + from agent.schema.land_options import sample_fallback_text + + return sample_fallback_text() FIRST_CHAT = "你好,我是询价机器人。请直接描述客户需求,例如「上海到洛杉矶空运,3 件 180kg,下周三前到」,我会解析需求并查询 TMS 标准报价。" ROUTE_MODEL_FAILED = "刚才没听清,请再说一次:是新询价,还是要接着办哪张工单?" LAND_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。" @@ -111,9 +136,8 @@ LAND_TMS_RETRYING = "正在重新查询TMS报价。工单:{wo}" _LAND_LCL_KEYS = ("起运港", "目的港", "品名", "体积", "毛重", "报价日期") _LAND_FTL_KEYS = ("起运港", "目的港", "品名", "车型/数量", "报价日期") _LAND_HK_FULL_KEYS = ("起运港", "目的港", "品名", "报价日期") -_LAND_LCL_TYPES = frozenset({"国内运输拼车", "跨境集拼"}) -_LAND_FTL_TYPES = frozenset({"国内运输整车", "跨境整车"}) -_LAND_CUSTOMS_TYPES = frozenset({"跨境集拼", "跨境整车", "中港"}) +_LAND_FTL_TYPES = frozenset({"国内运输整车", "跨境整车", "中港零担/集拼"}) +_LAND_CUSTOMS_TYPES = frozenset({"跨境集拼", "跨境整车", "中港整车", "中港零担/集拼"}) _LAND_EXAMPLES = { "起运港": "广州", "目的港": "深圳", @@ -127,11 +151,8 @@ _LAND_EXAMPLES = { def _land_cargo_keys(land_type: str, facts: dict[str, str] | None = None) -> tuple[str, ...]: """当前运输类型要展示的货物内部键。中港整车不要车型/数量。""" - if land_type == "中港": - load = str((facts or {}).get("中港装载方式") or "").strip() - if load == "零担/集拼": - return _LAND_FTL_KEYS - # 整车或还没选定装载:卡片/补问都不出现车型/数量 + _ = facts + if land_type == "中港整车": return _LAND_HK_FULL_KEYS if land_type in _LAND_FTL_TYPES: return _LAND_FTL_KEYS @@ -142,8 +163,9 @@ def land_confirm_card(facts: dict[str, str] | None) -> str: """ 确定查价前的核对卡。没有工单号。 - 行列出运输类型/线路/分类(中港再加装载)和该类型货物字段。 + 行列出运输类型/线路/分类和该类型货物字段。 国内不出现通关口岸;跨境/中港通关口岸非必填,没填留空行。 + 不再单独列出中港装载方式。 """ src = dict(facts or {}) land_type = (src.get("运输类型") or "").strip() @@ -152,8 +174,6 @@ def land_confirm_card(facts: dict[str, str] | None) -> str: f"线路类别(必填):{(src.get('线路类别') or '').strip()}", f"运输分类(必填):{(src.get('运输分类') or '').strip()}", ] - if land_type == "中港": - lines.append(f"中港装载方式(必填):{(src.get('中港装载方式') or '').strip()}") for key in _land_cargo_keys(land_type, src): label = LAND_DISPLAY.get(key, key) val = (src.get(key) or "").strip() @@ -170,6 +190,11 @@ def land_group_no_staff() -> str: return "没有可拉的产品人员。请先在后台「员工管理」维护本线路的陆运产品岗后再试。" +def land_group_pair_rejected() -> str: + """类型+线路不在填写样例里,禁止拉群。""" + return "拉群失败,请排查填写内容,并重新发起工单。" + + def ask_land_fields( *, facts: dict[str, str] | None = None, @@ -182,7 +207,7 @@ def ask_land_fields( 报价日期不进待补充。 """ src = dict(facts or {}) - skip = {"报价日期", "运输类型", "线路类别", "运输分类", "中港装载方式"} + skip = {"报价日期", "运输类型", "线路类别", "运输分类"} missing = [k for k in missing_keys if k and k not in skip] land_type = (land_subtype or src.get("运输类型") or "").strip() lines = ["请确认并补充以下信息:", "运输方式:陆运"] @@ -373,10 +398,6 @@ def inquiry_field_lines( f"线路类别:{(facts.get('线路类别') or '-').strip() or '-'}", f"运输分类:{(facts.get('运输分类') or '-').strip() or '-'}", ] - if land_type == "中港": - lines.append( - f"中港装载方式:{(facts.get('中港装载方式') or '-').strip() or '-'}" - ) for key in _land_cargo_keys(land_type, facts): label = LAND_DISPLAY.get(key, key) val = (facts.get(key) or "-").strip() or "-" @@ -1151,8 +1172,10 @@ def sea_group_brief( lines.append("") lines.append("协同补充字段:") collab = dict(collab_facts or {}) - for key in SEA_COLLAB_FIELDS: - label = SEA_COLLAB_DISPLAY.get(key, key) + line = "LAND" if mode == "陆运" else "SEA" + collab_keys = collab_fields_for_ticket(facts, line) or SEA_COLLAB_FIELDS + for key in collab_keys: + label = collab_display_name(key, line) val = (collab.get(key) or collab.get(label) or "").strip() or "-" lines.append(f"{label}:{val}") tms = dict(tms_quote) if tms_quote is not None else {} @@ -1298,14 +1321,10 @@ def scheme_horizontal_rows( def _land_scheme_type_line(facts: dict[str, str]) -> str: - """方案卡「类型」格:运输类型、中港装载、线路类别、运输分类。""" + """方案卡「类型」格:运输类型、线路类别、运输分类。""" src = facts or {} land_type = (src.get("运输类型") or "-").strip() or "-" bits = [land_type] - if land_type == "中港": - load = (src.get("中港装载方式") or "").strip() - if load: - bits.append(load) route = (src.get("线路类别") or "-").strip() or "-" klass = (src.get("运输分类") or "-").strip() or "-" bits.extend([route, klass]) @@ -1576,7 +1595,15 @@ def group_quote_not_recognized() -> str: return "没有识别到报价,请产品再发一次价格或 Excel/PDF" -def group_fields_not_recognized() -> str: +def group_fields_not_recognized( + keys: tuple[str, ...] | None = None, + business_line: str = "", +) -> str: + """没认出协同字段。陆运列出本单可补项;海运保持原六项口。""" + if keys: + line = (business_line or "").strip().upper() or "SEA" + names = [collab_display_name(k, line) for k in keys] + return "听到了。请直接说" + "、".join(names) + "。" return "听到了。请直接说贸易条款、货好时间、商品海关编码、是否含油/含电/含磁。" diff --git a/inquiry-agent/agent/policy/land_text_flow.py b/inquiry-agent/agent/policy/land_text_flow.py index 37a3612..db93440 100644 --- a/inquiry-agent/agent/policy/land_text_flow.py +++ b/inquiry-agent/agent/policy/land_text_flow.py @@ -1,7 +1,7 @@ """ 陆运文字询价确定性流程。 -本文件职责:选项联动 → 按类型补货物 → 无工单号核对卡 →(确定后)建单查 TMS。 +本文件职责:三项选项各自必填(不联动)→ 按类型补货物 → 无工单号核对卡 →(确定后)建单查 TMS。 查价后半段复用海运拉群/报价单/成交,拉人按线路类别,禁止陆运岗全员。 禁止:字段齐了就建单;直连 TMS;把海运「出卡即查价」改掉。 调用:handler 在 Worker/inbox 线程;禁止回调线程同步跑。 @@ -23,6 +23,8 @@ from agent.schema.land_options import ( harvest_land_options, looks_like_confirm, option_prompt, + option_prompt_needs_sample, + sample_pair_allowed, validate_land_options, ) from agent.schema.tms_land_query import assemble_land_query @@ -112,7 +114,7 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): subtype = str(snap.get("land_subtype") or facts.get("运输类型") or "").strip() if subtype and not str(facts.get("运输类型") or "").strip(): facts["运输类型"] = subtype - option_keys = ("运输类型", "线路类别", "运输分类", "中港装载方式") + option_keys = ("运输类型", "线路类别", "运输分类") base = dict(sess.facts) if sess else {} cargo = {k: v for k, v in facts.items() if k not in option_keys} merged = dict(base) @@ -184,6 +186,23 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): sess=sess, ) + def _reply_land_options( + self, + reply: ReplyFn, + facts: dict[str, str], + *, + message: str = "", + ) -> None: + """ + 选项补问:先发文字清单,需要样例时再发表格图。 + + 图片失败由出站回退文字样例。不在本方法上传素材。 + """ + body = (message or "").strip() or option_prompt(current=facts) + reply(body) + if option_prompt_needs_sample(current=facts): + reply(copy.land_sample_image_fallback(), copy.land_sample_image_payload()) + def _confirm_or_hold( self, *, @@ -221,7 +240,7 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): """ 销售确定之后才建陆运工单并查 TMS。 - 核对卡阶段没有工单号。运输分类、中港装载方式写入工单 facts,不进入 assemble 出站。 + 核对卡阶段没有工单号。运输分类写入工单 facts,不进入 assemble 出站。 """ import uuid @@ -363,10 +382,10 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): facts=merged, business_line="LAND", land_subtype=land_type ) cargo = dict(check["facts"]) - cargo.update({k: merged[k] for k in ("运输类型", "线路类别", "运输分类", "中港装载方式") if merged.get(k)}) + cargo.update({k: merged[k] for k in ("运输类型", "线路类别", "运输分类") if merged.get(k)}) tms = assemble_land_query(cargo) cargo = dict(tms.get("facts") or cargo) - cargo.update({k: merged[k] for k in ("运输类型", "线路类别", "运输分类", "中港装载方式") if merged.get(k)}) + cargo.update({k: merged[k] for k in ("运输类型", "线路类别", "运输分类") if merged.get(k)}) missing = list(check.get("missing") or []) for key in tms.get("missing") or []: if key not in missing and key not in {"运输类型", "线路类别"}: @@ -412,7 +431,11 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): return "wait_confirm" if state.get("stage") == "options_error": err = (state["opt"].get("errors") or [{}])[0] - reply(str(err.get("message") or option_prompt(current=facts))) + self._reply_land_options( + reply, + facts, + message=str(err.get("message") or ""), + ) self._save( FlowSession( sender_id=sender_id, @@ -427,12 +450,11 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): self._clear_parked_confirm(sender_id) return "need_land_options" if state["stage"] == "options_missing": - body = option_prompt(current=facts) logger.info( "land.options_missing missing=%s", (state.get("opt") or {}).get("missing") or [], ) - reply(body) + self._reply_land_options(reply, facts) self._save( FlowSession( sender_id=sender_id, @@ -574,6 +596,32 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): clear_pending_land_confirm(sender_id) + def _pull_collab_group( + self, + sess: FlowSession, + reply: ReplyFn, + card_meta: Optional[dict[str, Any]] = None, + ) -> str: + """ + 点「拉产品进群协同」:先核填写样例,再按线路拉人建群。 + + 类型+线路对不上样例:不建群,告诉销售重新开单。按钮保持可点。 + 对得上则走海运同一套建群。 + """ + facts = dict(sess.facts or {}) + land_type = str(facts.get("运输类型") or "").strip() + route = str(facts.get("线路类别") or "").strip() + if not sample_pair_allowed(land_type, route): + logger.info( + "land.group.reject pair type=%s route=%s wo=%s", + land_type or "-", + route or "-", + sess.work_order_no or "-", + ) + reply(copy.land_group_pair_rejected()) + return sess.phase or "wait_collab" + return super()._pull_collab_group(sess, reply, card_meta=card_meta) + def _match_sea_staff(self, sess: FlowSession) -> list[dict[str, Any]]: """按本单线路类别整词匹配产品·陆运,禁止全员。""" matcher = getattr(self._ledger, "match_land_staff", None) diff --git a/inquiry-agent/agent/policy/quote_overlay.py b/inquiry-agent/agent/policy/quote_overlay.py index 6b0eefc..3e039b6 100644 --- a/inquiry-agent/agent/policy/quote_overlay.py +++ b/inquiry-agent/agent/policy/quote_overlay.py @@ -24,6 +24,10 @@ _COLLAB_LABEL_MARKERS: tuple[tuple[str, tuple[str, ...]], ...] = ( ("是否含油", ("是否含油", "has_oil")), ("是否含电", ("是否含电", "has_battery")), ("是否含磁", ("是否含磁", "has_magnet")), + ("客户名称", ("客户名称", "customer_name")), + ("货值", ("货值", "cargo_value")), + ("是否为危险品", ("是否为危险品", "is_dangerous")), + ("包装方式", ("包装方式", "包装类型")), ) diff --git a/inquiry-agent/agent/policy/sea_group_ops.py b/inquiry-agent/agent/policy/sea_group_ops.py index fa03384..a2d2749 100644 --- a/inquiry-agent/agent/policy/sea_group_ops.py +++ b/inquiry-agent/agent/policy/sea_group_ops.py @@ -14,7 +14,12 @@ from typing import Any, Optional from agent.llm.extract_text import extract_collab_fields, extract_product_quote from agent.policy.quote_overlay import overlay_labeled_facts from agent.policy import inquiry_copy as copy -from agent.schema.field_validate import SEA_COLLAB_DISPLAY, SEA_COLLAB_FIELDS +from agent.schema.field_validate import ( + SEA_COLLAB_DISPLAY, + SEA_COLLAB_FIELDS, + collab_display_name, + collab_fields_for_ticket, +) # 主账填表按英文码取格子;中文键必须同时带上,否则货好时间/条款写不进表。 _COLLAB_FILL_ALIASES = { @@ -24,6 +29,10 @@ _COLLAB_FILL_ALIASES = { "是否含油": ("是否含油", "has_oil"), "是否含电": ("是否含电", "has_battery"), "是否含磁": ("是否含磁", "has_magnet"), + "客户名称": ("客户名称", "customer_name"), + "货值": ("货值", "cargo_value"), + "是否为危险品": ("是否为危险品", "is_dangerous"), + "包装方式": ("包装方式", "包装类型"), } logger = logging.getLogger(__name__) @@ -91,6 +100,24 @@ def ticket_no(ticket: object) -> str: return str(ticket_view(ticket).get("work_order_no") or "").strip() +def _collab_keys_of(ticket: object, sess: Any = None) -> tuple[str, tuple[str, ...]]: + """本单业务线 + 允许写入的协同内部键。""" + view = ticket_view(ticket) + facts = dict(view.get("facts") or {}) + if sess is not None and getattr(sess, "facts", None): + facts.update(dict(sess.facts or {})) + line = str( + view.get("business_line") + or view.get("businessLine") + or (getattr(sess, "business_line", "") if sess is not None else "") + or "SEA" + ).strip().upper() or "SEA" + keys = collab_fields_for_ticket(facts, line) + if not keys and line != "LAND": + keys = SEA_COLLAB_FIELDS + return line, keys + + def is_terminal(ticket: object) -> bool: return ticket_status(ticket) in TERMINAL @@ -378,6 +405,13 @@ def send_brief_and_sheet( quote = dict(override or sess_q or view_q or tms or manual) sales_name, products, mention_ids = resolve_group_mentions(flow, ticket, sess) has_tms = bool(tms.get("total") or _quote_fee_rows(tms)) + line = str( + view.get("business_line") + or view.get("businessLine") + or (sess.business_line if sess is not None else "") + or "SEA" + ).strip().upper() + mode = "陆运" if line == "LAND" else "海运" brief = copy.sea_group_brief( work_order_no=no, facts=facts, @@ -388,9 +422,16 @@ def send_brief_and_sheet( has_price=has_tms, tms_quote=tms, product_quote=manual, + transport_mode=mode, ) send_group_text(client, chat_id, brief, mention_userids=mention_ids) - send_filled_sheet(flow=flow, ticket=ticket, sess=sess, quote_override=quote) + send_filled_sheet( + flow=flow, + ticket=ticket, + sess=sess, + quote_override=quote, + business_line=line, + ) return "brief_and_sheet" @@ -400,7 +441,7 @@ def send_filled_sheet( ticket: object, sess: Any = None, quote_override: Optional[dict[str, Any]] = None, - business_line: str = "SEA", + business_line: str = "", chat_id: str = "", ) -> dict[str, Any]: """ @@ -429,7 +470,7 @@ def send_filled_sheet( flow=flow, chat_id=room, work_order_no=no, - facts=_facts_for_fill(facts, collab), + facts=_facts_for_fill(facts, collab, business_line=line or "SEA"), quote=quote, sales_id=str( (sess.sender_id if sess else "") @@ -441,15 +482,20 @@ def send_filled_sheet( ) -def _collab_value(collab: dict[str, str], key: str) -> str: - """协同六项:内部键、对外展示名都能取到。""" +def _collab_value(collab: dict[str, str], key: str, business_line: str = "SEA") -> str: + """协同项:内部键、对外展示名都能取到。""" src = dict(collab or {}) val = str(src.get(key) or "").strip() if val: return val - label = SEA_COLLAB_DISPLAY.get(key, "") + label = collab_display_name(key, business_line) if label: val = str(src.get(label) or "").strip() + if val: + return val + sea_label = SEA_COLLAB_DISPLAY.get(key, "") + if sea_label: + val = str(src.get(sea_label) or "").strip() return val @@ -466,15 +512,22 @@ def _oil_elec_mag_text(collab: dict[str, str]) -> str: return f"油:{oil or '-'};电:{elec or '-'};磁:{mag or '-'}" -def _facts_for_fill(facts: dict[str, str], collab: dict[str, str]) -> dict[str, str]: +def _facts_for_fill( + facts: dict[str, str], + collab: dict[str, str], + business_line: str = "SEA", +) -> dict[str, str]: """ - 询价七项 + 协同六项。中文键和填表英文码一起带上。 + 询价字段 + 本单协同项。中文键和填表英文码一起带上。 主账 writeFactsByLabel 只认 cargo_ready_date / trade_terms,只传「货好时间」会空着。 + 模板没有格子的新项照样带上,overlay 找不到标签就跳过。 """ merged = dict(facts or {}) src = dict(collab or {}) - for key in SEA_COLLAB_FIELDS: - val = _collab_value(src, key) + line = (business_line or "SEA").strip().upper() or "SEA" + keys = collab_fields_for_ticket(facts, line) or SEA_COLLAB_FIELDS + for key in keys: + val = _collab_value(src, key, line) if not val: continue for alias in _COLLAB_FILL_ALIASES.get(key, (key,)): @@ -549,7 +602,7 @@ def apply_collab_update( sender_id: str, sess: Any = None, ) -> str: - """补协同六项。终态要说明原因。""" + """补本单协同项。终态要说明原因。不该有的项当没补到。""" ticket = hydrate_ticket(flow, ticket) no = ticket_no(ticket) chat_id = str((sess.collab_chat_id if sess else "") or ticket_view(ticket).get("collab_chat_id") or "") @@ -557,11 +610,21 @@ def apply_collab_update( if is_terminal(ticket): send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket))) return "ticket_locked" - fields = extract_collab_fields(text, allow_b=True) + line, keys = _collab_keys_of(ticket, sess) + fields = extract_collab_fields(text, allow_b=True, allowed_keys=keys) if not fields: - send_group_text(client, chat_id, copy.group_fields_not_recognized()) + send_group_text( + client, + chat_id, + copy.group_fields_not_recognized(keys=keys or None, business_line=line), + ) return "collab_not_recognized" - flow.apply_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id) + flow.apply_collab_facts( + work_order_no=no, + facts=fields, + sender_id=sender_id, + allowed_keys=keys, + ) if sess is not None: merged = dict(sess.collab_facts) merged.update(fields) @@ -603,9 +666,15 @@ def apply_product_quote( if not is_product(ticket, sender_id, flow=flow): send_group_text(client, chat_id, copy.group_only_product_can_quote()) return "quote_not_product" - fields = extract_collab_fields(text) + fields = extract_collab_fields(text, allowed_keys=_collab_keys_of(ticket, sess)[1]) if fields: - flow.apply_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id) + line, keys = _collab_keys_of(ticket, sess) + flow.apply_collab_facts( + work_order_no=no, + facts=fields, + sender_id=sender_id, + allowed_keys=keys, + ) if sess is not None: merged = dict(sess.collab_facts) merged.update(fields) @@ -772,7 +841,7 @@ def _persist_quote_sheet( result = renderer( work_order_no=no, business_line=line, - facts=_facts_for_fill(facts, collab), + facts=_facts_for_fill(facts, collab, business_line=line or "SEA"), quote=quote, format="xlsx", sales_wecom_id=str( diff --git a/inquiry-agent/agent/policy/sea_text_flow.py b/inquiry-agent/agent/policy/sea_text_flow.py index 15f92af..a240c71 100644 --- a/inquiry-agent/agent/policy/sea_text_flow.py +++ b/inquiry-agent/agent/policy/sea_text_flow.py @@ -514,6 +514,24 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): product_ids=product_ids, ) + from agent.schema.field_validate import collab_fields_for_ticket, pick_collab_from_facts + + line = (sess.business_line or "SEA").upper() + keys = collab_fields_for_ticket(sess.facts, line) + seeded = pick_collab_from_facts(sess.facts, keys) + if seeded: + merged = dict(sess.collab_facts or {}) + for k, v in seeded.items(): + if v and not str(merged.get(k) or "").strip(): + merged[k] = v + sess.collab_facts = merged + self.apply_collab_facts( + work_order_no=no, + facts=seeded, + sender_id=sess.sender_id, + allowed_keys=keys, + ) + brief = copy.sea_group_brief( work_order_no=no, facts=sess.facts, @@ -638,6 +656,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): work_order_no: str, facts: dict[str, str], sender_id: str, + allowed_keys: tuple[str, ...] | None = None, ) -> dict[str, Any]: """ 群里静默写入协同字段。不改六态、不回复、不出报价版本。 @@ -647,10 +666,11 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): from agent.schema.field_validate import SEA_COLLAB_FIELDS no = (work_order_no or "").strip() + keys = tuple(allowed_keys) if allowed_keys is not None else SEA_COLLAB_FIELDS cleaned = { k: str(v).strip() for k, v in (facts or {}).items() - if k in SEA_COLLAB_FIELDS and str(v or "").strip() + if k in keys and str(v or "").strip() } if not no or not cleaned: return {"ok": False, "updated": []} diff --git a/inquiry-agent/agent/schema/field_validate.py b/inquiry-agent/agent/schema/field_validate.py index 286091d..6588234 100644 --- a/inquiry-agent/agent/schema/field_validate.py +++ b/inquiry-agent/agent/schema/field_validate.py @@ -64,6 +64,90 @@ AIR_COLLAB_FIELDS = ("是否含电", "是否含磁") SEA_COLLAB_DISPLAY = { "HS编码": "商品海关编码", } +# 陆运摘要标题:海关编码,不要写成海运的商品海关编码。 +LAND_COLLAB_DISPLAY = { + "HS编码": "海关编码", +} +# 填写样例允许拉群的类型+线路 → 协同内部键(顺序与规格一致)。 +_LAND_COLLAB_BY_PAIR: dict[tuple[str, str], tuple[str, ...]] = { + ("国内运输拼车", "国内长途/零担"): ( + "客户名称", + "包装方式", + "货值", + "是否为危险品", + ), + ("国内运输拼车", "国内城配/拖车/打包"): ( + "客户名称", + "包装方式", + "货值", + "是否为危险品", + ), + ("国内运输整车", "国内长途/零担"): ( + "客户名称", + "货值", + "包装方式", + "是否为危险品", + ), + ("国内运输整车", "国内城配/拖车/打包"): ( + "客户名称", + "货值", + "包装方式", + "是否为危险品", + ), + ("跨境集拼", "中港/中亚/中欧"): ( + "客户名称", + "HS编码", + "贸易条款", + "包装方式", + "是否为危险品", + ), + ("跨境集拼", "东南亚"): ( + "客户名称", + "HS编码", + "贸易条款", + "包装方式", + "是否为危险品", + ), + ("跨境整车", "中港/中亚/中欧"): ( + "客户名称", + "HS编码", + "贸易条款", + "货值", + "包装方式", + "是否为危险品", + ), + ("跨境整车", "东南亚"): ( + "客户名称", + "HS编码", + "贸易条款", + "货值", + "包装方式", + "是否为危险品", + ), + ("中港整车", "中港/中亚/中欧"): ( + "客户名称", + "HS编码", + "车型/数量", + "贸易条款", + "货值", + "包装方式", + "是否为危险品", + ), + ("中港零担/集拼", "中港/中亚/中欧"): ( + "客户名称", + "HS编码", + "贸易条款", + "包装方式", + "是否为危险品", + ), +} +# 意图分类用:海运六项 ∪ 陆运全部协同键。 +ALL_COLLAB_KEYS = tuple( + dict.fromkeys( + list(SEA_COLLAB_FIELDS) + + ["客户名称", "包装方式", "货值", "是否为危险品", "车型/数量"] + ) +) # 口语里已经带上 TMS 文档规定的单位时,补进空字段。 # 只认「数字+单位」,不猜港口,不把长宽高换算成立方。 @@ -102,6 +186,9 @@ _ALIASES = { "HS CODE": "HS编码", "商品海关编码": "HS编码", "海关编码": "HS编码", + "客户名称": "客户名称", + "货值": "货值", + "是否为危险品": "是否为危险品", } @@ -224,11 +311,75 @@ def harvest_oral_measures(text: str, facts: dict[str, str] | None = None) -> dic return harvest_named_air_route(raw, merged) +def land_collab_fields(land_type: str, route: str) -> tuple[str, ...]: + """ + 本单陆运协同内部键,顺序与规格一致。 + + 对不上填写样例返回空元组,调用方不得按海运六项去列。 + 海关编码内部仍是 HS编码。副作用:无。 + """ + from agent.schema.land_options import canonicalize_route, canonicalize_transport_type + + t = canonicalize_transport_type(land_type) or (land_type or "").strip() + r = canonicalize_route(route) or (route or "").strip() + return _LAND_COLLAB_BY_PAIR.get((t, r), ()) + + +def collab_fields_for_ticket(facts: dict[str, str] | None, business_line: str) -> tuple[str, ...]: + """ + 按业务线取出群摘要 / 写入允许的协同键。 + + LAND 看事实里的运输类型+线路类别;SEA/AIR 用固定名单。 + """ + line = (business_line or "").strip().upper() + if line == "AIR": + return AIR_COLLAB_FIELDS + if line == "LAND": + src = dict(facts or {}) + return land_collab_fields( + str(src.get("运输类型") or ""), + str(src.get("线路类别") or ""), + ) + return SEA_COLLAB_FIELDS + + +def collab_display_name(key: str, business_line: str) -> str: + """协同项对外标题。陆运 HS编码 → 海关编码;海运 → 商品海关编码。""" + line = (business_line or "").strip().upper() + if line == "LAND": + return LAND_COLLAB_DISPLAY.get(key, key) + return SEA_COLLAB_DISPLAY.get(key, key) + + +def pick_collab_from_facts( + facts: dict[str, str] | None, + keys: tuple[str, ...], +) -> dict[str, str]: + """ + 从私聊事实里取出已有协同键,供进群预填。 + + 只取 keys 里非空值。海关编码/商品海关编码经 normalize 折成 HS编码。 + 不在 keys 的询价项(如跨境整车车型/数量)不会被取出。 + 不覆盖调用方已有协同值;本函数只返回候选。 + """ + allowed = set(keys or ()) + if not allowed: + return {} + src = normalize_facts(facts) + out: dict[str, str] = {} + for key in keys: + val = (src.get(key) or "").strip() + if val: + out[key] = val + return out + + def harvest_oral_collab(text: str) -> dict[str, str]: """ - 群里口语协同三项:含油/含电/含磁、货好时间。 + 群里口语协同:含油/含电/含磁、货好时间、是否为危险品。 - 只认原话里已经说死的词(不含电、随时可提),不猜贸易条款和海关编码。 + 只认原话里已经说死的词(不含电、随时可提、普货),不猜贸易条款和海关编码。 + 危险品:否词(不是危险品/非危/普货)先于是词,避免「不是危险品」收成是。 """ raw = text or "" out: dict[str, str] = {} @@ -236,6 +387,11 @@ def harvest_oral_collab(text: str) -> dict[str, str]: ("是否含电", ("不含电", "不带电", "无电"), ("含电", "带电")), ("是否含油", ("不含油", "不带油", "无油"), ("含油", "带油")), ("是否含磁", ("不含磁", "不带磁", "无磁"), ("含磁", "带磁")), + ( + "是否为危险品", + ("不是危险品", "非危险品", "非危", "普货"), + ("危险品", "有危", "含危"), + ), ) for key, no_words, yes_words in yes_no: if any(w in raw for w in no_words): @@ -279,7 +435,8 @@ def _pricing_required_keys( land_subtype: str, facts: dict[str, str] | None = None, ) -> list[str]: - """当前激活的首次查价内部键列表。中港再按装载方式收窄。""" + """当前激活的首次查价内部键列表。中港整车/零担是两个运输类型。""" + _ = facts line = (business_line or "").strip().upper() pricing = contract.get("pricing_required") or {} if line == "SEA": @@ -291,14 +448,6 @@ def _pricing_required_keys( block = land.get(land_subtype) or {} if not block: return [] - if land_subtype == "中港": - from agent.schema.land_options import canonicalize_hk_load - - load = canonicalize_hk_load(str((facts or {}).get("中港装载方式") or "")) - by_load = block.get("by_hk_load") or {} - picked = by_load.get(load) if load else None - if isinstance(picked, list): - return list(picked) return list(block.get("always") or []) return [] diff --git a/inquiry-agent/agent/schema/land_options.py b/inquiry-agent/agent/schema/land_options.py index f5aae51..a7ca8d5 100644 --- a/inquiry-agent/agent/schema/land_options.py +++ b/inquiry-agent/agent/schema/land_options.py @@ -1,61 +1,64 @@ """ -陆运选项规则:运输类型、线路类别、运输分类、中港装载方式。 +陆运选项规则:运输类型、线路类别、运输分类。 -本文件职责:从销售原话收取标准项、校验联动、生成补问清单、判断「确定查价」意图。 -联动错误只纠正那一项能选什么,不整单作废。 -禁止:改六态;发明枚举;把选项当货物字段。 +本文件职责:从销售原话收取标准项、校验是否在名单内、生成补问清单(含填写样例表)、判断「确定查价」意图。 +三项各自必填,组合不拦截查价。拉群必须对上填写样例。运输分类句里的「中港/东南亚」不得冒充类型或线路。 +禁止:改六态;发明枚举;把选项当货物字段;再要单独的中港装载方式。 线程:纯函数,无共享可变状态,可在 Worker 槽调用。 """ from __future__ import annotations +from pathlib import Path from typing import Any -# 与后台员工管理陆运「负责线路」同一套字,禁止改字。 TRANSPORT_TYPES = ( "国内运输拼车", "国内运输整车", "跨境集拼", "跨境整车", - "中港", + "中港整车", + "中港零担/集拼", ) -DOMESTIC_ROUTES = ("国内长途/零担", "国内城配/拖车/打包") -CROSS_ROUTES = ("中港/中亚/中欧", "东南亚") -ROUTES_BY_TYPE = { - "国内运输拼车": DOMESTIC_ROUTES, - "国内运输整车": DOMESTIC_ROUTES, - "跨境集拼": CROSS_ROUTES, - "跨境整车": CROSS_ROUTES, - "中港": CROSS_ROUTES, -} -CROSS_CLASSES = ( - "普件,中港、东南亚常用线路/常规车型", - "普件,中亚、中欧、东南亚涉及国内+国外段", - "普件,中亚、中欧、东南亚涉及境外段清派等特殊要求", +ROUTES = ( + "国内长途/零担", + "国内城配/拖车/打包", + "中港/中亚/中欧", + "东南亚", +) +LAND_CLASSES = ( + "普件(东南亚、中港常用线路/常规车型)", + "普件(东南亚、中亚、中欧涉及国内+国外段)", + "普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)", "急件", ) -DOMESTIC_CLASSES = ( - "普件,常用线路/常规车型", - "普件,非常用线路/临时线路/特种车型", - "急件", -) -CLASSES_BY_ROUTE = { - "中港/中亚/中欧": CROSS_CLASSES, - "东南亚": CROSS_CLASSES, - "国内长途/零担": DOMESTIC_CLASSES, - "国内城配/拖车/打包": DOMESTIC_CLASSES, -} -HK_LOADS = ("整车", "零担/集拼") -# 口语别名 → 标准运输类型。长词优先,避免「拼车」误伤「整车」。 +# 填写样例,同时作为拉群门禁:对不上的类型+线路可以查价,但不能拉群。 +SAMPLE_PAIRS = ( + ("国内运输拼车", "国内长途/零担"), + ("国内运输拼车", "国内城配/拖车/打包"), + ("国内运输整车", "国内长途/零担"), + ("国内运输整车", "国内城配/拖车/打包"), + ("跨境集拼", "中港/中亚/中欧"), + ("跨境集拼", "东南亚"), + ("跨境整车", "中港/中亚/中欧"), + ("跨境整车", "东南亚"), + ("中港整车", "中港/中亚/中欧"), + ("中港零担/集拼", "中港/中亚/中欧"), +) + +# 口语别名 → 标准运输类型。长词优先。禁止把单独「中港」收成类型。 _TYPE_ALIASES = ( ("国内运输拼车", "国内运输拼车"), ("国内运输整车", "国内运输整车"), + ("中港零担/集拼", "中港零担/集拼"), + ("中港零担", "中港零担/集拼"), + ("中港集拼", "中港零担/集拼"), + ("中港整车", "中港整车"), ("国内拼车", "国内运输拼车"), ("国内整车", "国内运输整车"), ("跨境集拼", "跨境集拼"), ("跨境整车", "跨境整车"), - ("中港", "中港"), ) _ROUTE_ALIASES = ( @@ -67,20 +70,9 @@ _ROUTE_ALIASES = ( ("东南亚", "东南亚"), ) -# 分类全称从长到短;「急件」单独出现才算急件,避免普件句被误收。 +# 分类全称从长到短;「急件」单独出现才算急件。 _CLASS_CANON = tuple( - sorted( - {*(CROSS_CLASSES + DOMESTIC_CLASSES)}, - key=len, - reverse=True, - ) -) - -_HK_LOAD_ALIASES = ( - ("零担/集拼", "零担/集拼"), - ("零担", "零担/集拼"), - ("集拼", "零担/集拼"), - ("整车", "整车"), + sorted(LAND_CLASSES, key=len, reverse=True) ) _CONFIRM_PHRASES = ("可以查了", "确定", "确认", "查价") @@ -92,6 +84,7 @@ def canonicalize_transport_type(raw: str) -> str: 把销售原话收成标准运输类型。 参数:任意一句。返回 TRANSPORT_TYPES 之一或空串。 + 只说「中港」不算选完,避免分类句里的中港把类型改掉。 副作用:无。先匹配更长别名。 """ text = (raw or "").strip() @@ -104,7 +97,7 @@ def canonicalize_transport_type(raw: str) -> str: def canonicalize_route(raw: str) -> str: - """把原话收成标准线路类别;对不上返回空串。""" + """把原话收成标准线路类别;对不上返回空串。不把单独「中港」收成中港/中亚/中欧。""" text = (raw or "").strip() if not text: return "" @@ -136,14 +129,25 @@ def canonicalize_land_class(raw: str) -> str: return "" +def sample_pair_allowed(land_type: str, route: str) -> bool: + """ + 类型+线路是否在填写样例里。 + + 查价不调用本函数;点「拉产品进群协同」时必须为真,否则不建群。 + 入参先收成标准项。副作用:无。 + """ + t = canonicalize_transport_type(land_type) or (land_type or "").strip() + r = canonicalize_route(route) or (route or "").strip() + return (t, r) in SAMPLE_PAIRS + + def canonicalize_hk_load(raw: str) -> str: - """中港装载方式:整车或零担/集拼。""" - text = (raw or "").strip() - if not text: - return "" - for needle, canon in _HK_LOAD_ALIASES: - if needle in text: - return canon + """ + 旧中港装载方式不再收取。 + + 保留函数名避免旧调用处报缺符号;恒返回空串。新单用运输类型「中港整车」「中港零担/集拼」。 + """ + _ = raw return "" @@ -151,9 +155,9 @@ def harvest_land_options(text: str, current: dict[str, str] | None = None) -> di """ 从一句原话收取陆运选项,合并到已有值上。 + 先收运输分类并把命中句从原文挖掉,再收类型/线路,避免分类里的东南亚、中港冒充选项。 已有非空项默认保留;本句明确写出另一标准项时才覆盖。 - 已有值对不上标准枚举则丢掉,避免模型把「陆运」写成运输类型后整张清单重问。 - 不改货物字段。返回新 dict,不修改 current。 + 已有值对不上标准枚举则丢掉。不改货物字段。返回新 dict,不修改 current。 """ merged = { k: str(v).strip() @@ -162,23 +166,22 @@ def harvest_land_options(text: str, current: dict[str, str] | None = None) -> di } _keep_canonical_options(merged) blob = text or "" - found_type = canonicalize_transport_type(blob) - found_route = canonicalize_route(blob) found_class = canonicalize_land_class(blob) - found_load = canonicalize_hk_load(blob) + remainder = blob.replace(found_class, " ") if found_class else blob + found_type = canonicalize_transport_type(remainder) + found_route = canonicalize_route(remainder) if found_type: merged["运输类型"] = found_type if found_route: merged["线路类别"] = found_route if found_class: merged["运输分类"] = found_class - if found_load: - merged["中港装载方式"] = found_load + merged.pop("中港装载方式", None) return merged def _keep_canonical_options(merged: dict[str, str]) -> None: - """只留能对上枚举的选项;模型胡写的「陆运」不能冒充运输类型。""" + """只留能对上枚举的选项;模型胡写的「陆运」不能冒充运输类型。旧中港两项作废。""" t = canonicalize_transport_type(merged.get("运输类型") or "") if t: merged["运输类型"] = t @@ -194,138 +197,124 @@ def _keep_canonical_options(merged: dict[str, str]) -> None: merged["运输分类"] = c else: merged.pop("运输分类", None) - load = canonicalize_hk_load(merged.get("中港装载方式") or "") - if load: - merged["中港装载方式"] = load - else: - merged.pop("中港装载方式", None) + merged.pop("中港装载方式", None) def validate_land_options(opts: dict[str, str]) -> dict[str, Any]: """ - 校验选项是否齐全且联动正确。 + 校验三项是否都在名单内。组合不拦截。 - 返回 ok / missing / errors。errors 每项 {field, message},只纠正错的那一项。 - 中港缺装载方式进 missing,不改货物字段。 + 返回 ok / missing / errors。对不上名单的项当缺项,不写联动纠正句。 副作用:无。 """ src = {k: str(v).strip() for k, v in dict(opts or {}).items() if str(v).strip()} - t = canonicalize_transport_type(src.get("运输类型") or "") or src.get("运输类型") or "" - r = canonicalize_route(src.get("线路类别") or "") or src.get("线路类别") or "" - c = canonicalize_land_class(src.get("运输分类") or "") or src.get("运输分类") or "" - load = canonicalize_hk_load(src.get("中港装载方式") or "") or src.get("中港装载方式") or "" + t = canonicalize_transport_type(src.get("运输类型") or "") + r = canonicalize_route(src.get("线路类别") or "") + c = canonicalize_land_class(src.get("运输分类") or "") missing: list[str] = [] - errors: list[dict[str, str]] = [] - if t not in TRANSPORT_TYPES: missing.append("运输类型") t = "" - - if t: - allowed_routes = ROUTES_BY_TYPE.get(t) or () - if not r: - missing.append("线路类别") - elif r not in allowed_routes: - errors.append( - { - "field": "线路类别", - "message": "线路类别只能选:" + "、".join(f"「{x}」" for x in allowed_routes), - } - ) - r = "" - elif r: - # 类型还没有,线路先记下但不判联动 - pass - - if r: - allowed_cls = CLASSES_BY_ROUTE.get(r) or () - if not c: - missing.append("运输分类") - elif c not in allowed_cls: - errors.append( - { - "field": "运输分类", - "message": "运输分类只能选:" + "、".join(f"「{x}」" for x in allowed_cls), - } - ) - c = "" - elif "运输分类" not in missing and not c: + if r not in ROUTES: + missing.append("线路类别") + r = "" + if c not in LAND_CLASSES: missing.append("运输分类") + c = "" - if t == "中港": - if load not in HK_LOADS: - missing.append("中港装载方式") - ok = not missing and not errors + ok = not missing return { "ok": ok, "missing": missing, - "errors": errors, + "errors": [], "normalized": { "运输类型": t, "线路类别": r, "运输分类": c, - "中港装载方式": load if t == "中港" else "", + "中港装载方式": "", }, } +def _class_prompt_lines() -> list[str]: + lines = ["运输分类(必填):可选择回复,"] + for i, name in enumerate(LAND_CLASSES, start=1): + lines.append(f"{i}.{name}") + return lines + + +def option_prompt_needs_sample(*, current: dict[str, str]) -> bool: + """运输类型或线路类别缺任一,就要发填写样例图。只缺分类不发。""" + src = {k: str(v).strip() for k, v in dict(current or {}).items() if str(v).strip()} + t = canonicalize_transport_type(src.get("运输类型") or "") + r = canonicalize_route(src.get("线路类别") or "") + return t not in TRANSPORT_TYPES or r not in ROUTES + + +def sample_image_path() -> Path: + """填写样例表 PNG。和源码放一起,随 agent 发布包走。""" + return Path(__file__).resolve().parent.parent / "assets" / "land_sample_pairs.png" + + +def sample_fallback_text() -> str: + """ + 图片发不出去时的文字兜底。 + + 一行一种搭配,不再画框线。 + """ + lines = ["填写样例"] + for i, (typ, route) in enumerate(SAMPLE_PAIRS, start=1): + lines.append(f"{i}.{typ} + {route}") + return "\n".join(lines) + + +def _sample_table() -> str: + """兼容旧名:补问正文不再内嵌样例,只作图片失败兜底。""" + return sample_fallback_text() + + def option_prompt(*, current: dict[str, str]) -> str: """ 按已识别项生成补问清单。 - 全缺:一次列出类型、线路、分类、中港装载。 - 类型已知:不再问类型,只列合法线路/分类。 + 缺哪项列哪项。运输类型或线路类别缺任一则提示见图,样例图另发出站。只缺分类不附图。 不修改 current。 """ src = {k: str(v).strip() for k, v in dict(current or {}).items() if str(v).strip()} - t = canonicalize_transport_type(src.get("运输类型") or "") or src.get("运输类型") or "" - r = canonicalize_route(src.get("线路类别") or "") or src.get("线路类别") or "" + t = canonicalize_transport_type(src.get("运输类型") or "") + r = canonicalize_route(src.get("线路类别") or "") + c = canonicalize_land_class(src.get("运输分类") or "") + need_type = t not in TRANSPORT_TYPES + need_route = r not in ROUTES + need_class = c not in LAND_CLASSES lines: list[str] = [] - if t not in TRANSPORT_TYPES: - lines.append("请一次选择:运输类型、线路类别、运输分类。") + if need_type and need_route and need_class: + lines.append("请一次选择:运输类型、线路类别、运输分类") lines.append("") - lines.append("运输类型:") - lines.append("、".join(TRANSPORT_TYPES)) + lines.append("运输类型(必填):可选择回复," + "、".join(TRANSPORT_TYPES)) lines.append("") - lines.append("线路类别(跟运输类型联动):") - lines.append("国内运输拼车 / 国内运输整车:" + "、".join(DOMESTIC_ROUTES)) - lines.append("跨境集拼 / 跨境整车 / 中港:" + "、".join(CROSS_ROUTES)) + lines.append("线路类别(必填):可选择回复," + "、".join(ROUTES)) lines.append("") - lines.append("运输分类(跟线路类别联动):") - lines.append("中港/中亚/中欧、东南亚:" + ";".join(CROSS_CLASSES)) - lines.append("国内长途/零担、国内城配/拖车/打包:" + ";".join(DOMESTIC_CLASSES)) + lines.extend(_class_prompt_lines()) lines.append("") - lines.append("运输类型选中港时,还要选择:" + "、".join(HK_LOADS)) + lines.append("填写样例见图。") return "\n".join(lines).strip() - # 类型已认出来:不再问运输类型 - allowed_routes = ROUTES_BY_TYPE.get(t) or () - if r not in allowed_routes: - lines.append(f"运输类型已选「{t}」。请选择线路类别:") - lines.append("、".join(allowed_routes)) - route_for_class = "" - else: - route_for_class = r - - if route_for_class: - allowed_cls = CLASSES_BY_ROUTE.get(route_for_class) or () - if src.get("运输分类") not in allowed_cls: - lines.append(f"线路类别已选「{route_for_class}」。请选择运输分类:") - lines.append(";".join(allowed_cls)) - elif t in TRANSPORT_TYPES: - # 线路未定,把该类型下各线路的分类一并列出,方便一次粘贴 - seen: set[str] = set() - lines.append("运输分类(跟线路类别联动):") - for route in allowed_routes: - if route in seen: - continue - seen.add(route) - lines.append(f"{route}:" + ";".join(CLASSES_BY_ROUTE.get(route) or ())) - - if t == "中港" and (src.get("中港装载方式") not in HK_LOADS): - lines.append("中港请再选择装载方式:" + "、".join(HK_LOADS)) + if need_type: + lines.append("运输类型(必填):可选择回复," + "、".join(TRANSPORT_TYPES)) + if need_route: + if lines: + lines.append("") + lines.append("线路类别(必填):可选择回复," + "、".join(ROUTES)) + if need_class: + if lines: + lines.append("") + lines.extend(_class_prompt_lines()) + if need_type or need_route: + lines.append("") + lines.append("填写样例见图。") return "\n".join(lines).strip() diff --git a/inquiry-agent/agent/schema/tms_land_query.py b/inquiry-agent/agent/schema/tms_land_query.py index 2cfefec..4880785 100644 --- a/inquiry-agent/agent/schema/tms_land_query.py +++ b/inquiry-agent/agent/schema/tms_land_query.py @@ -4,8 +4,8 @@ 本文件职责:核对卡通过后、建单查价前,确认当前运输类型的货物字段能拼出查价正文。 带去:运输类型、线路类别、始发/目的/品名/报价日期,以及该类型的体积重量或车型/数量;通关口岸有值才带。 出站类型/线路必须译成 TMS 6.1.1 英文枚举,线路键名 routeCategory。工单与卡片仍用中文。 -中港整车不带车型/数量。车型/数量是一个字段,出站再拆。 -禁止:把运输分类、中港装载方式放进出站;直连 TMS;改六态。 +中港整车、中港零担/集拼出站类型都是 CHINA_HK。中港整车不带车型/数量。 +禁止:把运输分类放进出站;直连 TMS;改六态。 线程:纯函数。 """ @@ -16,20 +16,21 @@ import re from typing import Any from agent.schema.field_validate import apply_quote_date_default, normalize_facts -from agent.schema.land_options import canonicalize_hk_load logger = logging.getLogger(__name__) LCL_TYPES = frozenset({"国内运输拼车", "跨境集拼"}) -FTL_TYPES = frozenset({"国内运输整车", "跨境整车"}) -CUSTOMS_TYPES = frozenset({"跨境集拼", "跨境整车", "中港"}) +FTL_TYPES = frozenset({"国内运输整车", "跨境整车", "中港零担/集拼"}) +HK_FTL_TYPE = "中港整车" +CUSTOMS_TYPES = frozenset({"跨境集拼", "跨境整车", "中港整车", "中港零担/集拼"}) _TYPE_ENUM = { "国内运输拼车": "DOMESTIC_LTL", "国内运输整车": "DOMESTIC_FTL", "跨境集拼": "CROSS_BORDER_LTL", "跨境整车": "CROSS_BORDER_FTL", - "中港": "CHINA_HK", + "中港整车": "CHINA_HK", + "中港零担/集拼": "CHINA_HK", } _ROUTE_ENUM = { "国内长途/零担": "DOMESTIC_LONG_LTL", @@ -91,13 +92,12 @@ def assemble_land_query(facts: dict[str, Any] | None) -> dict[str, Any]: """ 把已采集陆运字段拼成 TMS 查价参数。 - 不齐则 ok=False,missing 给销售补问。齐了也不带分类/中港装载。 + 不齐则 ok=False,missing 给销售补问。齐了也不带分类。 改了运输类型后,outbound 只带当前类型需要的键。 """ src = apply_quote_date_default( normalize_facts({str(k): v for k, v in dict(facts or {}).items() if v is not None}) ) - # 选项键不走港口别名折叠,再从原文补回 raw = {str(k): str(v).strip() for k, v in dict(facts or {}).items() if v is not None} land_type = (raw.get("运输类型") or src.get("运输类型") or "").strip() route = (raw.get("线路类别") or src.get("线路类别") or "").strip() @@ -106,7 +106,6 @@ def assemble_land_query(facts: dict[str, Any] | None) -> dict[str, Any]: cargo = (src.get("品名") or "").strip() quote_date = (src.get("报价日期") or "").strip() customs = (raw.get("通关口岸") or src.get("通关口岸") or "").strip() - hk_load = canonicalize_hk_load(raw.get("中港装载方式") or src.get("中港装载方式") or "") missing: list[str] = [] if not land_type: @@ -141,10 +140,9 @@ def assemble_land_query(facts: dict[str, Any] | None) -> dict[str, Any]: missing.append("毛重") else: outbound["weightKg"] = wgt - elif land_type == "中港" and hk_load != "零担/集拼": - # 中港整车(或尚未选定装载)不采集、不带出车型/数量 + elif land_type == HK_FTL_TYPE: pass - elif land_type in FTL_TYPES or land_type == "中港": + elif land_type in FTL_TYPES: combo = ( src.get("车型/数量") or raw.get("车型/数量") diff --git a/inquiry-agent/tests/test_air_group_collab.py b/inquiry-agent/tests/test_air_group_collab.py index 83a6ec7..ded89e8 100644 --- a/inquiry-agent/tests/test_air_group_collab.py +++ b/inquiry-agent/tests/test_air_group_collab.py @@ -226,6 +226,37 @@ class AirGroupCollabTests(unittest.TestCase): self.assertEqual(phase, "activated") self.assertEqual(self.ledger.find_by_collab_chat(chat_id="wr_air_1").work_order_no, wo2) + def test_activate_prefills_magnet(self) -> None: + created = self.ledger.create_ticket( + sender_id="sales1", + business_line="AIR", + facts={ + "起运地": "上海", + "目的地": "洛杉矶", + "品名": "配件", + "件数": "3", + "毛重": "100", + "体积": "2", + "包装类型": "纸箱", + "报价日期": "2026-09-16", + "是否含磁": "是", + }, + ) + wo = created["work_order_no"] + self.ledger.upsert_quote( + work_order_no=wo, + quote={"total": "USD 800", "source": "TMS", "quoteId": "QT-AIR-1"}, + to_status="已报价", + ) + handle_air_group( + self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="pre1"), + flow=self.flow, + ledger=self.ledger, + ) + joined = "\n".join(self._texts()) + self.assertIn("是否含磁:是", joined) + self.assertIn("是否含电:-", joined) + def test_second_group_occupied(self) -> None: wo = self._create_air() handle_air_group( diff --git a/inquiry-agent/tests/test_land_collab_fields.py b/inquiry-agent/tests/test_land_collab_fields.py new file mode 100644 index 0000000..e0032a5 --- /dev/null +++ b/inquiry-agent/tests/test_land_collab_fields.py @@ -0,0 +1,102 @@ +""" +陆运群协同项:六套名单、展示名、预填、口语危险品。 +不连企微 / TMS / 主账。 +""" + +from __future__ import annotations + +import os +import sys +import unittest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +from agent.llm.extract_text import extract_collab_fields +from agent.schema.field_validate import ( + collab_display_name, + collab_fields_for_ticket, + harvest_oral_collab, + land_collab_fields, + pick_collab_from_facts, +) + + +class LandCollabFieldsTest(unittest.TestCase): + def test_domestic_ltl_keys(self): + keys = land_collab_fields("国内运输拼车", "国内长途/零担") + self.assertEqual(keys, ("客户名称", "包装方式", "货值", "是否为危险品")) + + def test_domestic_ftl_longhaul_keys(self): + keys = land_collab_fields("国内运输整车", "国内长途/零担") + self.assertEqual(keys, ("客户名称", "货值", "包装方式", "是否为危险品")) + + def test_domestic_ftl_order(self): + keys = land_collab_fields("国内运输整车", "国内城配/拖车/打包") + self.assertEqual(keys, ("客户名称", "货值", "包装方式", "是否为危险品")) + + def test_cross_ftl_no_vehicle(self): + keys = land_collab_fields("跨境整车", "东南亚") + self.assertIn("HS编码", keys) + self.assertNotIn("车型/数量", keys) + + def test_hk_ftl_has_vehicle(self): + keys = land_collab_fields("中港整车", "中港/中亚/中欧") + self.assertIn("车型/数量", keys) + self.assertEqual(keys[0], "客户名称") + + def test_unlisted_pair_empty(self): + self.assertEqual(land_collab_fields("国内运输拼车", "东南亚"), ()) + + def test_land_hs_display(self): + self.assertEqual(collab_display_name("HS编码", "LAND"), "海关编码") + self.assertEqual(collab_display_name("HS编码", "SEA"), "商品海关编码") + + def test_ticket_sea_air(self): + self.assertIn("是否含电", collab_fields_for_ticket({}, "SEA")) + self.assertEqual( + collab_fields_for_ticket({}, "AIR"), + ("是否含电", "是否含磁"), + ) + + def test_pick_skips_inquiry_vehicle(self): + got = pick_collab_from_facts( + {"包装方式": "纸箱", "车型/数量": "9米/1", "海关编码": "8479"}, + land_collab_fields("跨境整车", "东南亚"), + ) + self.assertEqual(got.get("包装方式"), "纸箱") + self.assertEqual(got.get("HS编码"), "8479") + self.assertNotIn("车型/数量", got) + + def test_dg_oral(self): + self.assertEqual(harvest_oral_collab("普货")["是否为危险品"], "否") + self.assertEqual(harvest_oral_collab("危险品")["是否为危险品"], "是") + self.assertEqual(harvest_oral_collab("不是危险品")["是否为危险品"], "否") + + def test_labeled_without_colon(self): + allowed = land_collab_fields("国内运输拼车", "国内长途/零担") + got = extract_collab_fields( + "客户名称张三 包装方式编织袋 货值 10万", + allowed_keys=allowed, + ) + self.assertEqual(got.get("客户名称"), "张三") + self.assertEqual(got.get("包装方式"), "编织袋") + self.assertEqual(got.get("货值"), "10万") + + def test_bare_pack_ignored(self): + allowed = land_collab_fields("国内运输拼车", "国内长途/零担") + self.assertEqual(extract_collab_fields("编织袋", allowed_keys=allowed), {}) + + def test_trade_terms_filtered_for_ltl(self): + allowed = land_collab_fields("国内运输拼车", "国内长途/零担") + got = extract_collab_fields("客户名称张三,贸易条款 FOB", allowed_keys=allowed) + self.assertEqual(got, {"客户名称": "张三"}) + + def test_sea_labeled_still_works(self): + got = extract_collab_fields("贸易条款:FOB") + self.assertEqual(got.get("贸易条款"), "FOB") + + +if __name__ == "__main__": + unittest.main() diff --git a/inquiry-agent/tests/test_land_copy.py b/inquiry-agent/tests/test_land_copy.py index fccf9aa..ec313fd 100644 --- a/inquiry-agent/tests/test_land_copy.py +++ b/inquiry-agent/tests/test_land_copy.py @@ -53,13 +53,12 @@ class LandCopyTest(unittest.TestCase): ) self.assertIn("通关口岸(非必填):", text) - def test_hk_shows_load(self): + def test_hk_lcl_shows_vehicle_not_load_field(self): text = copy.land_confirm_card( { - "运输类型": "中港", + "运输类型": "中港零担/集拼", "线路类别": "东南亚", "运输分类": "急件", - "中港装载方式": "零担/集拼", "起运港": "广州", "目的港": "香港", "品名": "衣服", @@ -67,25 +66,26 @@ class LandCopyTest(unittest.TestCase): "报价日期": "2026-09-17", } ) - self.assertIn("零担/集拼", text) + self.assertIn("运输类型(必填):中港零担/集拼", text) self.assertIn("车型/数量(必填):9米6/1", text) - self.assertNotIn("\n数量(必填)", text) + self.assertNotIn("中港装载方式", text) + self.assertIn("通关口岸(非必填)", text) def test_hk_full_truck_hides_vehicle(self): text = copy.land_confirm_card( { - "运输类型": "中港", + "运输类型": "中港整车", "线路类别": "东南亚", "运输分类": "急件", - "中港装载方式": "整车", "起运港": "广州", "目的港": "香港", "品名": "衣服", "报价日期": "2026-09-17", } ) - self.assertIn("整车", text) + self.assertIn("运输类型(必填):中港整车", text) self.assertNotIn("车型/数量", text) + self.assertNotIn("中港装载方式", text) def test_domestic_ftl_one_field(self): text = copy.land_confirm_card( @@ -134,7 +134,7 @@ class LandCopyTest(unittest.TestCase): facts = { "运输类型": "国内运输拼车", "线路类别": "国内城配/拖车/打包", - "运输分类": "普件,常用线路/常规车型", + "运输分类": "普件(东南亚、中港常用线路/常规车型)", "起运港": "泉州市", "目的港": "南京市", "品名": "通信机柜及线缆配件", @@ -201,6 +201,66 @@ class LandCopyTest(unittest.TestCase): self.assertIn("9米6/1", rows["货物"]) self.assertNotIn("CBM", rows["货物"]) + def test_land_brief_only_ltl_collab(self): + text = copy.sea_group_brief( + work_order_no="WO1", + facts={ + "运输类型": "国内运输拼车", + "线路类别": "国内长途/零担", + "运输分类": "急件", + "起运港": "广州", + "目的港": "深圳", + "品名": "衣服", + "体积": "1", + "毛重": "1", + "报价日期": "2026-09-18", + }, + quote=None, + collab_facts={"包装方式": "纸箱"}, + sales_name="销", + product_names=["产"], + has_price=False, + transport_mode="陆运", + ) + self.assertIn("包装方式:纸箱", text) + self.assertIn("客户名称:-", text) + self.assertIn("货值:-", text) + self.assertIn("是否为危险品:-", text) + self.assertNotIn("贸易条款", text) + self.assertNotIn("商品海关编码", text) + self.assertNotIn("海关编码", text) + + def test_land_brief_ftl_longhaul_order(self): + text = copy.sea_group_brief( + work_order_no="WO2", + facts={ + "运输类型": "国内运输整车", + "线路类别": "国内长途/零担", + "运输分类": "急件", + "起运港": "广州", + "目的港": "深圳", + "品名": "衣服", + "车型/数量": "9米/1", + "报价日期": "2026-09-18", + }, + quote=None, + collab_facts={}, + sales_name="销", + product_names=["产"], + has_price=False, + transport_mode="陆运", + ) + collab = text.split("协同补充字段")[1] + self.assertLess(collab.find("客户名称"), collab.find("货值")) + self.assertLess(collab.find("货值"), collab.find("包装方式")) + self.assertNotIn("贸易条款", text) + + def test_land_unrecognized_lists_keys(self): + keys = ("客户名称", "包装方式", "货值", "是否为危险品") + text = copy.group_fields_not_recognized(keys=keys, business_line="LAND") + self.assertIn("客户名称", text) + self.assertNotIn("货好时间", text) + if __name__ == "__main__": unittest.main() diff --git a/inquiry-agent/tests/test_land_field_validate.py b/inquiry-agent/tests/test_land_field_validate.py index c94a05d..77a5c46 100644 --- a/inquiry-agent/tests/test_land_field_validate.py +++ b/inquiry-agent/tests/test_land_field_validate.py @@ -33,10 +33,9 @@ class LandFieldValidateTest(unittest.TestCase): "品名": "衣服", "体积": "10CBM", "毛重": "50kg", - "中港装载方式": "零担/集拼", }, business_line="LAND", - land_subtype="中港", + land_subtype="中港零担/集拼", ) self.assertIn("车型/数量", got["missing"]) self.assertNotIn("数量", got["missing"]) @@ -48,10 +47,9 @@ class LandFieldValidateTest(unittest.TestCase): "起运港": "广州", "目的港": "香港", "品名": "衣服", - "中港装载方式": "整车", }, business_line="LAND", - land_subtype="中港", + land_subtype="中港整车", ) self.assertTrue(got["ok"]) self.assertNotIn("车型/数量", got["missing"]) diff --git a/inquiry-agent/tests/test_land_group_collab.py b/inquiry-agent/tests/test_land_group_collab.py new file mode 100644 index 0000000..579c0e1 --- /dev/null +++ b/inquiry-agent/tests/test_land_group_collab.py @@ -0,0 +1,305 @@ +""" +陆运群内协同:摘要只列本单协同项、补字段过滤、预填。 +不连企微 / 真实 TMS / 8180。 +""" + +from __future__ import annotations + +import os +import sys +import unittest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +os.environ.setdefault("YTD_ENV", "test") +os.environ.setdefault("REDIS_BACKEND", "memory") +os.environ.setdefault("CHECKPOINT_BACKEND", "memory") +os.environ.setdefault("MESSAGE_STORE_BACKEND", "memory") +os.environ.setdefault("LLM_DATA_USAGE_CONFIRMED", "false") +os.environ.setdefault("LLM_ALLOW_NETWORK", "false") +os.environ.setdefault("LEDGER_BACKEND", "memory") + +from agent.channel.wecom.models import InboundMessage +from agent.handlers.group_collab import handle_group_collab +from agent.ledger.memory_ledger import MemoryLedger +from agent.policy import inquiry_copy as copy +from agent.policy.land_text_flow import LandTextInquiryFlow +from agent.routing.group_intent import INTENT_FIELDS, INTENT_QUOTE + + +class CountingLedger(MemoryLedger): + def __init__(self) -> None: + super().__init__() + self.last_created_no = "" + + def create_ticket(self, **kwargs): + out = super().create_ticket(**kwargs) + self.last_created_no = str(out.get("work_order_no") or "") + return out + + +class FakeGroupClient: + def __init__(self) -> None: + self.created: list[dict] = [] + self.sent: list[dict] = [] + + def create_group(self, *, name: str, userids: list[str]) -> dict: + self.created.append({"name": name, "userids": list(userids)}) + return {"ok": True, "chat_id": "wr_land_collab", "error": ""} + + def add_group_members(self, *, chat_id: str, userids: list[str]) -> dict: + return {"ok": True} + + def send_group(self, *, chat_id: str, content: str, mention_userids: list | None = None) -> dict: + self.sent.append({"chat_id": chat_id, "content": content, "kind": "text"}) + return {"ok": True} + + def send_group_file(self, *, chat_id: str, filename: str, filepath: str = "", file_b64: str = "") -> dict: + self.sent.append({"chat_id": chat_id, "filename": filename, "kind": "file"}) + return {"ok": True} + + +class LandGroupCollabTests(unittest.TestCase): + def setUp(self) -> None: + self.ledger = CountingLedger() + self.ledger.set_quote_templates( + [ + { + "templateId": "mem-default-land", + "name": "陆运默认", + "bizType": "陆运", + "keywords": [], + "isDefault": True, + } + ] + ) + self.ledger.set_land_staff( + [ + { + "roleCode": "land", + "status": "active", + "wecomId": "p_dom", + "name": "乙", + "routes": "国内长途/零担,国内城配/拖车/打包,中港/中亚/中欧,东南亚", + } + ] + ) + self.groups = FakeGroupClient() + self.flow = LandTextInquiryFlow(self.ledger, group_client=self.groups) + self.replies: list[str] = [] + + def _reply(self, text: str, extra=None) -> str: + self.replies.append(text) + return "" + + def _group_texts(self) -> str: + return "\n".join(x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text") + + def _open( + self, + *, + option_text: str, + extra_facts: dict | None = None, + cargo: dict | None = None, + ) -> tuple[str, str]: + facts = { + "起运港": "广州", + "目的港": "深圳", + "品名": "衣服", + "体积": "100CBM", + "毛重": "50kg", + } + facts.update(cargo or {}) + facts.update(extra_facts or {}) + self.flow.on_text( + sender_id="u1", + text=option_text, + reply=self._reply, + injected_facts=facts, + injected_mode="LAND", + ) + self.flow.on_text(sender_id="u1", text="确定", reply=self._reply) + self.replies.clear() + phase = self.flow.on_button( + sender_id="u1", + action=copy.BTN_PULL_COLLAB, + reply=self._reply, + ) + self.assertEqual(phase, "collab_group") + wo = self.flow.session_of("u1").work_order_no + return wo, "wr_land_collab" + + def _msg(self, *, sender: str, text: str, mid: str) -> InboundMessage: + return InboundMessage( + sender_id=sender, + message_id=mid, + content=text, + msg_type="text", + chat_id="wr_land_collab", + chat_type="group", + ) + + def test_ltl_brief_only_four_collab_and_prefill_pack(self) -> None: + self._open( + option_text="国内运输拼车 国内长途/零担 急件 包装方式纸箱", + extra_facts={"包装方式": "纸箱"}, + ) + brief = self._group_texts() + self.assertIn("包装方式:纸箱", brief) + self.assertIn("客户名称:-", brief) + self.assertIn("货值:-", brief) + self.assertIn("是否为危险品:-", brief) + self.assertNotIn("贸易条款", brief) + self.assertNotIn("商品海关编码", brief) + self.assertNotIn("海关编码", brief) + + def test_domestic_ftl_longhaul_can_pull_and_brief_order(self) -> None: + """国内整车配长途:允许拉群;协同顺序是客户名称、货值、包装方式、是否为危险品。""" + self._open( + option_text="国内运输整车 国内长途/零担 急件", + cargo={"车型/数量": "9米/1"}, + ) + self.assertTrue(self.groups.created) + brief = self._group_texts() + collab_part = brief.split("协同补充字段")[1] + self.assertIn("客户名称:-", collab_part) + self.assertIn("货值:-", collab_part) + self.assertIn("包装方式:-", collab_part) + self.assertIn("是否为危险品:-", collab_part) + self.assertLess(collab_part.find("客户名称"), collab_part.find("货值")) + self.assertLess(collab_part.find("货值"), collab_part.find("包装方式")) + self.assertLess(collab_part.find("包装方式"), collab_part.find("是否为危险品")) + self.assertNotIn("贸易条款", brief) + self.assertNotIn("海关编码", brief) + # 车型/数量只在询价字段,不进协同。 + self.assertIn("车型/数量:9米/1", brief.split("协同补充字段")[0]) + self.assertNotIn("车型/数量", collab_part) + + def test_cross_ftl_vehicle_stays_inquiry(self) -> None: + wo, _ = self._open( + option_text="跨境整车 东南亚 急件", + cargo={ + "目的港": "河内", + "车型/数量": "9米/1", + }, + ) + brief = self._group_texts() + self.assertIn("车型/数量:9米/1", brief.split("协同补充字段")[0]) + self.assertNotIn("车型/数量", brief.split("协同补充字段")[1]) + self.groups.sent.clear() + phase = handle_group_collab( + self._msg(sender="u1", text="车型/数量 12米/1", mid="v1"), + flow=self.flow, + ledger=self.ledger, + injected_intent=INTENT_FIELDS, + ) + self.assertEqual(phase, "collab_not_recognized") + ticket = self.ledger.get_ticket(work_order_no=wo) + self.assertEqual(ticket.facts.get("车型/数量"), "9米/1") + self.assertFalse(ticket.collab_facts.get("车型/数量")) + + def test_hk_ftl_vehicle_is_collab(self) -> None: + wo, _ = self._open( + option_text="中港整车 中港/中亚/中欧 急件", + cargo={"目的港": "香港"}, + ) + brief = self._group_texts() + collab_part = brief.split("协同补充字段")[1] + self.assertIn("车型/数量:-", collab_part) + self.assertNotIn("车型/数量", brief.split("协同补充字段")[0]) + self.groups.sent.clear() + phase = handle_group_collab( + self._msg(sender="u1", text="车型/数量 9米/1", mid="hk1"), + flow=self.flow, + ledger=self.ledger, + injected_intent=INTENT_FIELDS, + ) + self.assertEqual(phase, "collab_field_saved") + ticket = self.ledger.get_ticket(work_order_no=wo) + self.assertEqual(ticket.collab_facts.get("车型/数量"), "9米/1") + self.assertFalse(ticket.facts.get("车型/数量")) + self.assertIn("车型/数量:9米/1", self._group_texts()) + + def test_ltl_rejects_trade_terms_only(self) -> None: + self._open(option_text="国内运输拼车 国内长途/零担 急件") + self.groups.sent.clear() + phase = handle_group_collab( + self._msg(sender="u1", text="贸易条款 FOB", mid="t1"), + flow=self.flow, + ledger=self.ledger, + injected_intent=INTENT_FIELDS, + ) + self.assertEqual(phase, "collab_not_recognized") + text = self._group_texts() + self.assertIn("客户名称", text) + self.assertNotIn("货好时间", text) + + def test_ltl_keeps_name_drops_trade_terms(self) -> None: + wo, _ = self._open(option_text="国内运输拼车 国内长途/零担 急件") + self.groups.sent.clear() + phase = handle_group_collab( + self._msg(sender="u1", text="客户名称张三,贸易条款 FOB", mid="t2"), + flow=self.flow, + ledger=self.ledger, + injected_intent=INTENT_FIELDS, + ) + self.assertEqual(phase, "collab_field_saved") + ticket = self.ledger.get_ticket(work_order_no=wo) + self.assertEqual(ticket.collab_facts.get("客户名称"), "张三") + self.assertFalse(ticket.collab_facts.get("贸易条款")) + self.assertIn("客户名称:张三", self._group_texts()) + + def test_dg_and_value_and_bare_pack(self) -> None: + wo, _ = self._open(option_text="国内运输拼车 国内长途/零担 急件") + handle_group_collab( + self._msg(sender="u1", text="普货", mid="dg1"), + flow=self.flow, + ledger=self.ledger, + injected_intent=INTENT_FIELDS, + ) + ticket = self.ledger.get_ticket(work_order_no=wo) + self.assertEqual(ticket.collab_facts.get("是否为危险品"), "否") + handle_group_collab( + self._msg(sender="u1", text="危险品", mid="dg2"), + flow=self.flow, + ledger=self.ledger, + injected_intent=INTENT_FIELDS, + ) + ticket = self.ledger.get_ticket(work_order_no=wo) + self.assertEqual(ticket.collab_facts.get("是否为危险品"), "是") + handle_group_collab( + self._msg(sender="u1", text="货值 10万 包装方式编织袋", mid="v1"), + flow=self.flow, + ledger=self.ledger, + injected_intent=INTENT_FIELDS, + ) + ticket = self.ledger.get_ticket(work_order_no=wo) + self.assertEqual(ticket.collab_facts.get("货值"), "10万") + self.assertEqual(ticket.collab_facts.get("包装方式"), "编织袋") + self.groups.sent.clear() + phase = handle_group_collab( + self._msg(sender="u1", text="编织袋", mid="v2"), + flow=self.flow, + ledger=self.ledger, + injected_intent=INTENT_FIELDS, + ) + self.assertEqual(phase, "collab_not_recognized") + + def test_product_can_quote_without_collab(self) -> None: + wo, _ = self._open(option_text="国内运输拼车 国内长途/零担 急件") + phase = handle_group_collab( + self._msg(sender="p_dom", text="运费 1000", mid="q1"), + flow=self.flow, + ledger=self.ledger, + injected_intent=INTENT_QUOTE, + injected_quote={"total": "1000", "source_label": "产品报价"}, + ) + self.assertIn(phase, {"product_quote_sent", "wait_tms_choice"}) + ticket = self.ledger.get_ticket(work_order_no=wo) + self.assertTrue(ticket.quote) + + +if __name__ == "__main__": + unittest.main() diff --git a/inquiry-agent/tests/test_land_options.py b/inquiry-agent/tests/test_land_options.py index eb25527..0d140aa 100644 --- a/inquiry-agent/tests/test_land_options.py +++ b/inquiry-agent/tests/test_land_options.py @@ -1,5 +1,5 @@ """ -陆运选项联动单测:类型/线路/分类/中港装载,选错只纠正该项。 +陆运选项单测:三项各自必填、不联动;中港拆进运输类型;样例表跟类型/线路缺项走。 不连企微、主账、TMS。 """ @@ -18,12 +18,14 @@ from agent.schema.land_options import ( harvest_land_options, looks_like_confirm, option_prompt, + option_prompt_needs_sample, + sample_pair_allowed, validate_land_options, ) class LandOptionsTest(unittest.TestCase): - def test_domestic_ltl_rejects_sea_route(self): + def test_any_type_route_combo_ok(self): got = validate_land_options( { "运输类型": "国内运输拼车", @@ -31,28 +33,70 @@ class LandOptionsTest(unittest.TestCase): "运输分类": "急件", } ) - self.assertFalse(got["ok"]) - msg = got["errors"][0]["message"] - self.assertIn("国内长途/零担", msg) - self.assertIn("国内城配/拖车/打包", msg) + self.assertTrue(got["ok"]) + self.assertEqual(got["normalized"]["运输类型"], "国内运输拼车") + self.assertEqual(got["normalized"]["线路类别"], "东南亚") + + def test_bare_hk_does_not_fill_type_or_route(self): + cur = harvest_land_options("中港 急件") + self.assertFalse(cur.get("运输类型")) + self.assertFalse(cur.get("线路类别")) + self.assertEqual(cur.get("运输分类"), "急件") + + def test_hk_full_and_lcl_types(self): + self.assertEqual( + harvest_land_options("中港整车")["运输类型"], "中港整车" + ) + self.assertEqual( + harvest_land_options("中港零担/集拼 东南亚")["运输类型"], + "中港零担/集拼", + ) + self.assertEqual( + harvest_land_options("中港零担")["运输类型"], "中港零担/集拼" + ) + + def test_new_class_names(self): + cur = harvest_land_options( + "普件(东南亚、中港常用线路/常规车型)" + ) + self.assertEqual( + cur["运输分类"], "普件(东南亚、中港常用线路/常规车型)" + ) + self.assertFalse( + harvest_land_options("普件,常用线路/常规车型").get("运输分类") + ) + + def test_no_hk_load_field(self): + got = validate_land_options( + { + "运输类型": "中港整车", + "线路类别": "东南亚", + "运输分类": "急件", + } + ) + self.assertTrue(got["ok"]) + self.assertNotIn("中港装载方式", got["missing"]) + self.assertFalse(got["normalized"].get("中港装载方式")) def test_followup_route_class_keeps_type(self): cur = harvest_land_options( - "国内城配/拖车/打包;普件,常用线路/常规车型", + "国内城配/拖车/打包;普件(东南亚、中港常用线路/常规车型)", current={"运输类型": "国内运输拼车"}, ) self.assertEqual(cur["运输类型"], "国内运输拼车") self.assertEqual(cur["线路类别"], "国内城配/拖车/打包") - self.assertEqual(cur["运输分类"], "普件,常用线路/常规车型") + self.assertEqual( + cur["运输分类"], "普件(东南亚、中港常用线路/常规车型)" + ) def test_model_land_does_not_wipe_type(self): dropped = harvest_land_options( - "国内城配/拖车/打包;普件,常用线路/常规车型", + "国内城配/拖车/打包;急件", current={"运输类型": "陆运"}, ) self.assertFalse(dropped.get("运输类型")) cur = harvest_land_options( - "国内城配/拖车/打包;普件,常用线路/常规车型", + "国内城配/拖车/打包;急件", current={"运输类型": "国内运输拼车"}, ) junk = harvest_land_options("陆运", current=cur) @@ -64,36 +108,66 @@ class LandOptionsTest(unittest.TestCase): def test_full_prompt_when_empty(self): text = option_prompt(current={}) - self.assertIn("运输类型", text) - self.assertIn("线路类别", text) - self.assertIn("运输分类", text) - self.assertIn("国内运输拼车", text) - self.assertIn("整车", text) - self.assertNotIn("请补充陆运类型:", text) + self.assertIn("请一次选择:运输类型、线路类别、运输分类", text) + self.assertIn("运输类型(必填):可选择回复,", text) + self.assertIn("中港整车", text) + self.assertIn("中港零担/集拼", text) + self.assertIn("线路类别(必填):可选择回复,", text) + self.assertIn("国内长途/零担", text) + self.assertIn("1.普件(东南亚、中港常用线路/常规车型)", text) + self.assertIn("填写样例见图", text) + self.assertNotIn("国内运输整车 + 国内城配/拖车/打包", text) + self.assertNotIn("┌", text) + self.assertNotIn("| 国内运输整车 |", text) + self.assertNotIn("仅供参考", text) + self.assertNotIn("中港装载方式", text) + self.assertNotIn("跟运输类型联动", text) - def test_copy_alias_lists_all_three(self): + def test_copy_alias_lists_new_types(self): from agent.policy import inquiry_copy as copy text = copy.ASK_LAND_SUBTYPE - self.assertIn("运输类型", text) - self.assertIn("线路类别", text) - self.assertIn("运输分类", text) + self.assertIn("中港整车", text) + self.assertIn("填写样例", text) self.assertNotIn("请补充陆运类型:国内运输拼车", text) - def test_narrow_prompt_when_type_known(self): + def test_sample_table_when_route_missing(self): text = option_prompt(current={"运输类型": "国内运输拼车"}) - self.assertIn("国内长途/零担", text) - self.assertNotIn("请选择运输类型", text) + self.assertIn("线路类别(必填)", text) + self.assertIn("填写样例见图", text) + self.assertTrue(option_prompt_needs_sample(current={"运输类型": "国内运输拼车"})) + self.assertNotIn("国内运输拼车 + 国内长途/零担", text) + self.assertNotIn("请一次选择:运输类型", text) + self.assertNotIn("运输类型(必填)", text) - def test_hk_needs_load(self): - got = validate_land_options( - { - "运输类型": "中港", + def test_sample_pairs_go_to_image_not_text(self): + from agent.policy import inquiry_copy as copy + + text = option_prompt(current={}) + self.assertIn("填写样例见图", text) + self.assertNotIn("国内运输拼车 + 国内长途/零担", text) + extra = copy.land_sample_image_payload() + self.assertEqual(extra.get("msgtype"), "image") + self.assertTrue(os.path.isfile(str(extra.get("filepath") or ""))) + self.assertGreater(os.path.getsize(str(extra["filepath"])), 1000) + + def test_no_sample_when_only_class_missing(self): + text = option_prompt( + current={ + "运输类型": "国内运输拼车", "线路类别": "东南亚", - "运输分类": "急件", } ) - self.assertIn("中港装载方式", got["missing"]) + self.assertIn("运输分类(必填)", text) + self.assertNotIn("填写样例", text) + + def test_sample_pair_gate(self): + self.assertTrue(sample_pair_allowed("国内运输拼车", "国内长途/零担")) + self.assertTrue(sample_pair_allowed("国内运输整车", "国内城配/拖车/打包")) + self.assertTrue(sample_pair_allowed("国内运输整车", "国内长途/零担")) + self.assertTrue(sample_pair_allowed("中港整车", "中港/中亚/中欧")) + self.assertFalse(sample_pair_allowed("国内运输拼车", "东南亚")) + self.assertFalse(sample_pair_allowed("中港整车", "东南亚")) def test_confirm_intent(self): self.assertTrue(looks_like_confirm("确认")) diff --git a/inquiry-agent/tests/test_land_text_inquiry.py b/inquiry-agent/tests/test_land_text_inquiry.py index 0eeac60..97c7c81 100644 --- a/inquiry-agent/tests/test_land_text_inquiry.py +++ b/inquiry-agent/tests/test_land_text_inquiry.py @@ -47,9 +47,11 @@ class LandTextInquiryFrontTests(unittest.TestCase): self.ledger = CountingLedger() self.flow = LandTextInquiryFlow(self.ledger) self.replies: list[str] = [] + self.extras: list[dict] = [] def _reply(self, text: str, extra=None) -> str: self.replies.append(text) + self.extras.append(dict(extra or {})) return "" def _joined(self) -> str: @@ -69,7 +71,12 @@ class LandTextInquiryFrontTests(unittest.TestCase): self.assertIn("国内运输拼车", text) self.assertIn("线路类别", text) self.assertIn("运输分类", text) + self.assertIn("中港整车", text) + self.assertIn("填写样例见图", text) self.assertNotIn("请补充陆运类型:", text) + img = [x for x in self.extras if x.get("msgtype") == "image"] + self.assertEqual(len(img), 1) + self.assertTrue(os.path.isfile(str(img[0].get("filepath") or ""))) def test_known_type_does_not_reask_type(self) -> None: phase = self.flow.on_text( @@ -82,8 +89,10 @@ class LandTextInquiryFrontTests(unittest.TestCase): self.assertEqual(phase, "need_land_options") self.assertNotIn("请选择运输类型", self._joined()) self.assertIn("国内长途/零担", self._joined()) + self.assertIn("填写样例见图", self._joined()) + self.assertTrue(any(x.get("msgtype") == "image" for x in self.extras)) - def test_wrong_route_keeps_type(self) -> None: + def test_unlinked_route_still_keeps_type(self) -> None: phase = self.flow.on_text( sender_id="u1", text="国内运输拼车,线路东南亚", @@ -92,14 +101,16 @@ class LandTextInquiryFrontTests(unittest.TestCase): injected_mode="LAND", ) self.assertEqual(phase, "need_land_options") - self.assertIn("国内长途/零担", self._joined()) + self.assertIn("运输分类", self._joined()) + self.assertNotIn("填写样例", self._joined()) sess = self.flow.session_of("u1") self.assertEqual(sess.facts.get("运输类型"), "国内运输拼车") + self.assertEqual(sess.facts.get("线路类别"), "东南亚") def test_hk_lcl_asks_vehicle(self) -> None: phase = self.flow.on_text( sender_id="u1", - text="中港 东南亚 急件 零担", + text="中港零担/集拼 东南亚 急件", reply=self._reply, injected_facts={ "起运港": "广州", @@ -119,7 +130,7 @@ class LandTextInquiryFrontTests(unittest.TestCase): def test_hk_full_truck_skips_vehicle(self) -> None: phase = self.flow.on_text( sender_id="u1", - text="中港 东南亚 急件 整车", + text="中港整车 东南亚 急件", reply=self._reply, injected_facts={ "起运港": "广州", @@ -403,7 +414,7 @@ class LandTextConfirmTests(unittest.TestCase): self.replies.clear() phase = self.flow.on_text( sender_id="u1", - text="国内城配/拖车/打包;普件,常用线路/常规车型", + text="国内城配/拖车/打包;普件(东南亚、中港常用线路/常规车型)", reply=self._reply, injected_facts={"品名": "通信机柜及线缆配件", "运输类型": "陆运"}, injected_mode="LAND", @@ -413,7 +424,7 @@ class LandTextConfirmTests(unittest.TestCase): self.assertNotIn("请一次选择:运输类型", text) self.assertIn("国内运输拼车", text) self.assertIn("国内城配/拖车/打包", text) - self.assertIn("普件,常用线路/常规车型", text) + self.assertIn("普件(东南亚、中港常用线路/常规车型)", text) class FakeGroupClient: @@ -513,6 +524,36 @@ class LandTextPullTests(unittest.TestCase): self.assertEqual(self.groups.created, []) self.assertEqual(phase, "wait_collab") + def test_pull_rejects_unlisted_pair(self) -> None: + self.ledger.set_land_staff( + [ + {"roleCode": "land", "status": "active", "wecomId": "p_sea", "name": "甲", "routes": "东南亚"}, + ] + ) + self.flow.on_text( + sender_id="u1", + text="国内运输拼车 东南亚 急件", + reply=self._reply, + injected_facts={ + "起运港": "广州", + "目的港": "河内", + "品名": "衣服", + "体积": "100CBM", + "毛重": "50kg", + }, + injected_mode="LAND", + ) + self.flow.on_text(sender_id="u1", text="确定", reply=self._reply) + self.replies.clear() + phase = self.flow.on_button( + sender_id="u1", + action=copy.BTN_PULL_COLLAB, + reply=self._reply, + ) + self.assertEqual(self.groups.created, []) + self.assertIn("拉群失败,请排查填写内容,并重新发起工单。", "\n".join(self.replies)) + self.assertNotEqual(phase, "collab_group") + def test_land_role_is_product_in_group(self) -> None: from agent.policy.sea_group_ops import is_product diff --git a/inquiry-agent/tests/test_sea_text_inquiry.py b/inquiry-agent/tests/test_sea_text_inquiry.py index 35e8253..0d790f8 100644 --- a/inquiry-agent/tests/test_sea_text_inquiry.py +++ b/inquiry-agent/tests/test_sea_text_inquiry.py @@ -452,6 +452,39 @@ class SeaSkipAndPullTests(unittest.TestCase): ) self.assertEqual(again, "card_discard") + def test_pull_prefills_spoken_no_battery(self) -> None: + self.ledger.set_sea_staff( + [ + { + "name": "张产品", + "wecomId": "prod1", + "roleCode": "sea", + "routes": "海运·全部线路", + "status": "active", + } + ] + ) + facts = dict(COMPLETE_SEA) + facts["是否含电"] = "否" + self.flow.on_text( + sender_id="s1", + text="海运不含电", + reply=self._reply, + injected_facts=facts, + injected_mode="SEA", + ) + private = "\n".join(self.replies) + self.assertNotIn("是否含电", private) + self.flow.on_button( + sender_id="s1", + action=copy.BTN_PULL_COLLAB, + reply=self._reply, + ) + group_text = "\n".join( + x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text" + ) + self.assertIn("是否含电:否", group_text) + def test_pull_sends_sheet_when_client_not_injected(self) -> None: """测服正式路径不注入 group_client,拉群仍要发出填好的模板。""" from unittest.mock import patch diff --git a/inquiry-agent/tests/test_tms_land_query.py b/inquiry-agent/tests/test_tms_land_query.py index a6f750a..4e2d75d 100644 --- a/inquiry-agent/tests/test_tms_land_query.py +++ b/inquiry-agent/tests/test_tms_land_query.py @@ -18,10 +18,9 @@ from agent.schema.tms_land_query import assemble_land_query class TmsLandQueryTest(unittest.TestCase): def test_outbound_omits_class_and_hk_load(self): facts = { - "运输类型": "中港", + "运输类型": "中港零担/集拼", "线路类别": "东南亚", "运输分类": "急件", - "中港装载方式": "零担/集拼", "起运港": "广州", "目的港": "香港", "品名": "衣服", @@ -44,9 +43,8 @@ class TmsLandQueryTest(unittest.TestCase): def test_hk_full_truck_omits_vehicle(self): facts = { - "运输类型": "中港", + "运输类型": "中港整车", "线路类别": "东南亚", - "中港装载方式": "整车", "起运港": "广州", "目的港": "香港", "品名": "衣服", diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapper.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapper.java index 6008fca..b8997fd 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapper.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapper.java @@ -19,6 +19,8 @@ public final class TmsLandEnumMapper { TYPE.put("国内运输整车", "DOMESTIC_FTL"); TYPE.put("跨境集拼", "CROSS_BORDER_LTL"); TYPE.put("跨境整车", "CROSS_BORDER_FTL"); + TYPE.put("中港整车", "CHINA_HK"); + TYPE.put("中港零担/集拼", "CHINA_HK"); TYPE.put("中港", "CHINA_HK"); TYPE.put("DOMESTIC_LTL", "DOMESTIC_LTL"); TYPE.put("DOMESTIC_FTL", "DOMESTIC_FTL"); diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapper.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapper.java index 442babd..9f79b7c 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapper.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapper.java @@ -15,9 +15,10 @@ import java.util.regex.Pattern; /** * 陆运 TMS 查价段:按运输类型拼出站字段。 *

- * 职责:把工单 facts 里的中文键写成 TMS 段;运输分类、中港装载方式只留在工单,不进出站。 - * 拼车/跨境集拼带体积重量;国内整车/跨境整车带「车型/数量」一项再拆成出站车型与台数; - * 中港整车不带车型/数量,中港零担/集拼才带。通关口岸有值才放。 + * 职责:把工单 facts 里的中文键写成 TMS 段;运输分类只留在工单,不进出站。 + * 拼车/跨境集拼带体积重量;国内整车/跨境整车/中港零担带「车型/数量」一项再拆成出站车型与台数; + * 中港整车不带车型/数量。通关口岸有值才放。 + * 中港整车、中港零担/集拼出站类型都是 CHINA_HK。 * 类型/线路出站必须是 TMS 6.1.1 英文枚举,线路键名 routeCategory。 * 禁止:按空运要包装件数;把分类当查价条件;直连 TMS。 */ @@ -26,9 +27,9 @@ public final class TmsLandSegmentMapper { /** 体积+重量那一套。 */ private static final Set LCL = new HashSet( Arrays.asList("国内运输拼车", "跨境集拼")); - /** 车型/数量那一套。中港不在此列,按装载方式另判。 */ + /** 车型/数量那一套。中港零担走这里;中港整车另判。 */ private static final Set FTL = new HashSet( - Arrays.asList("国内运输整车", "跨境整车")); + Arrays.asList("国内运输整车", "跨境整车", "中港零担/集拼")); private static final Pattern VEHICLE_SPLIT = Pattern.compile("\\s*[/xX×**]\\s*"); private static final Pattern TAIL_QTY = Pattern.compile("^(.*?)[\\s,,]+(\\d+)\\s*(台|辆)?$"); @@ -37,7 +38,7 @@ public final class TmsLandSegmentMapper { } /** - * @param facts 工单/智能体中文 facts(含运输类型、线路类别、货物、中港装载方式) + * @param facts 工单/智能体中文 facts(含运输类型、线路类别、货物) * @return 可并入查价 segment 的键;不含 landClass / hkLoadType */ public static Map build(Map facts) { @@ -45,7 +46,6 @@ public final class TmsLandSegmentMapper { Map seg = new LinkedHashMap(); String type = text(source.get("运输类型")); String route = text(source.get("线路类别")); - String hkLoad = text(source.get("中港装载方式")); put(seg, "landTransportType", TmsLandEnumMapper.transportType(type)); put(seg, "routeCategory", TmsLandEnumMapper.routeCategory(route)); put(seg, "origin", text(source.get("起运港"))); @@ -54,9 +54,9 @@ public final class TmsLandSegmentMapper { if (LCL.contains(type)) { put(seg, "volumeCbm", TmsV2RequestMapper.asPositiveDecimal(source.get("体积"))); put(seg, "weightKg", TmsV2RequestMapper.asPositiveDecimal(source.get("毛重"))); - } else if ("中港".equals(type) && !"零担/集拼".equals(hkLoad)) { + } else if ("中港整车".equals(type)) { // 中港整车:询价不要车型/数量,出站也不带 - } else if (FTL.contains(type) || "中港".equals(type)) { + } else if (FTL.contains(type)) { putVehicleQty(source, seg); } String customs = text(source.get("通关口岸")); @@ -68,9 +68,9 @@ public final class TmsLandSegmentMapper { /** * 陆运出站拦网:工单号、报价日期、起运目的品名、类型、线路,再加当前类型货物。 - * 不要包装、不要运输分类、不要中港装载。中港整车不要车型/数量。 + * 不要包装、不要运输分类。中港整车不要车型/数量。 * - * @param facts 用于读中港装载方式;禁止把装载方式写进 segment + * @param facts 用于读运输类型;禁止把分类写进 segment */ public static List missingQuery(String workOrderNo, String quoteDate, Map segment, @@ -93,14 +93,14 @@ public final class TmsLandSegmentMapper { } String typeEnum = text(seg.get("landTransportType")); String cnType = text(facts == null ? null : facts.get("运输类型")); - String hkLoad = text(facts == null ? null : facts.get("中港装载方式")); boolean lcl = LCL.contains(cnType) || "DOMESTIC_LTL".equals(typeEnum) || "CROSS_BORDER_LTL".equals(typeEnum); - boolean hk = "中港".equals(cnType) || "CHINA_HK".equals(typeEnum); + boolean hkFull = "中港整车".equals(cnType); boolean ftl = FTL.contains(cnType) || "DOMESTIC_FTL".equals(typeEnum) - || "CROSS_BORDER_FTL".equals(typeEnum); + || "CROSS_BORDER_FTL".equals(typeEnum) + || "中港零担/集拼".equals(cnType); if (lcl) { if (seg.get("volumeCbm") == null) { missing.add("volumeCbm"); @@ -108,9 +108,9 @@ public final class TmsLandSegmentMapper { if (seg.get("weightKg") == null) { missing.add("weightKg"); } - } else if (hk && !"零担/集拼".equals(hkLoad)) { + } else if (hkFull) { // 中港整车不拦车型/数量 - } else if (ftl || hk) { + } else if (ftl) { if (blank(seg.get("vehicleType"))) { missing.add("vehicleType"); } diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapperTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapperTest.java index 7e6cd3b..9944f23 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapperTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapperTest.java @@ -15,6 +15,12 @@ public class TmsLandEnumMapperTest { assertEquals("DOMESTIC_CITY_DIST", TmsLandEnumMapper.routeCategory("国内城配/拖车/打包")); } + @Test + public void hkSplitTypesBothMapChinaHk() { + assertEquals("CHINA_HK", TmsLandEnumMapper.transportType("中港整车")); + assertEquals("CHINA_HK", TmsLandEnumMapper.transportType("中港零担/集拼")); + } + @Test public void alreadyEnumStays() { assertEquals("DOMESTIC_LTL", TmsLandEnumMapper.transportType("DOMESTIC_LTL")); diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapperTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapperTest.java index 16a7b16..5f318ee 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapperTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapperTest.java @@ -38,15 +38,15 @@ public class TmsLandSegmentMapperTest { @Test public void landFtlUsesVehicleNotVolume() { Map facts = new HashMap(); - facts.put("运输类型", "中港"); + facts.put("运输类型", "中港零担/集拼"); facts.put("线路类别", "中港/中亚/中欧"); - facts.put("中港装载方式", "零担/集拼"); facts.put("起运港", "广州"); facts.put("目的港", "香港"); facts.put("品名", "衣服"); facts.put("车型/数量", "9米6/1"); facts.put("体积", "99"); Map seg = TmsLandSegmentMapper.build(facts); + assertEquals("CHINA_HK", seg.get("landTransportType")); assertEquals("9米6", seg.get("vehicleType")); assertNull(seg.get("volumeCbm")); assertFalse(seg.containsKey("hkLoadType")); @@ -56,13 +56,13 @@ public class TmsLandSegmentMapperTest { @Test public void hkFullTruckOmitsVehicle() { Map facts = new HashMap(); - facts.put("运输类型", "中港"); + facts.put("运输类型", "中港整车"); facts.put("线路类别", "中港/中亚/中欧"); - facts.put("中港装载方式", "整车"); facts.put("起运港", "广州"); facts.put("目的港", "香港"); facts.put("品名", "衣服"); Map seg = TmsLandSegmentMapper.build(facts); + assertEquals("CHINA_HK", seg.get("landTransportType")); assertNull(seg.get("vehicleType")); assertNull(seg.get("quantity")); java.util.List missing = TmsLandSegmentMapper.missingQuery(