From 1f902bc49b765eabd8aff593691563d2b1919b59 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Fri, 6 Nov 2026 09:58:06 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AI=20=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=E8=BD=AE=E5=90=8E=E8=B7=AF=E7=BA=BF=E7=A4=BA=E6=84=8F=E5=8F=AA?= =?UTF-8?q?=E5=89=A9=E4=B8=80=E4=B8=AA=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因: runPlan 追加轮把 state.nearby 置 null, payload.nearby 随之为空, 模型第二版不再点名具体地点; extractPlanPlaces 的 nearby 来源(带坐标)空了, 加粗词又无坐标, 无 Key 时全被判 missing -> 只剩 1 个已选目的地。 - 追加轮沿用 state.nearby 并继续放进上下文; 提示词要求保留加粗点名 - extractPlanPlaces(text, inherit) 继承上一版坐标 + 否定语境护栏 - planVersions 存 routePoints, 版本切换/回到初版/载入保存方案同步重绘 - previous 改用 currentPlanText(), drawRouteMap 用 state.routeMissing - 新增 test-route-map-followup.cjs / verify-route-map-followup.cjs 与 README 说明 --- README.md | 38 ++++- lib/planner.js | 4 +- preview/preview-route-followup.png | Bin 0 -> 34099 bytes public/app.js | 125 ++++++++++++-- tools/test-route-map-followup.cjs | 162 ++++++++++++++++++ tools/verify-route-map-followup.cjs | 245 ++++++++++++++++++++++++++++ 6 files changed, 551 insertions(+), 23 deletions(-) create mode 100644 preview/preview-route-followup.png create mode 100644 tools/test-route-map-followup.cjs create mode 100644 tools/verify-route-map-followup.cjs diff --git a/README.md b/README.md index 473013b..79272ac 100644 --- a/README.md +++ b/README.md @@ -183,18 +183,20 @@ node tools/build-admin-dv.cjs C:/tmp/datav-admin.json #### 离线回归测试 -归属逻辑不能只靠真实网络验证——Photon / Overpass 随时会挂,挂了就没法回归。仓库里有三个**不发外部请求**的离线测试: +归属逻辑不能只靠真实网络验证——Photon / Overpass 随时会挂,挂了就没法回归。仓库里有**不发外部请求**的离线测试: ```bash -node tools/test-admin-lookup.cjs # 归属判定单测:同名县消歧、已知边界点、狭长县远端点 -node tools/test-overpass-fallback.cjs # 起本地假 Overpass:验兜底路径、简称归一、200+HTML 错误页 -node tools/test-grouping-regression.cjs # 起本地假 Photon:20 个候选的完整分组回归 +node tools/test-admin-lookup.cjs # 归属判定单测:同名县消歧、已知边界点、狭长县远端点 +node tools/test-overpass-fallback.cjs # 起本地假 Overpass:验兜底路径、简称归一、200+HTML 错误页 +node tools/test-grouping-regression.cjs # 起本地假 Photon:20 个候选的完整分组回归 +node tools/test-route-map-followup.cjs # vm 里跑真实 extractPlanPlaces:初版/追加轮两版路线点对比 + 源码断言 ``` -另有端到端 UI 验收(起假 Photon + 真实无头 Chrome,读真实 DOM): +另有端到端 UI 验收(真实无头 Chrome,读真实 DOM): ```bash -node tools/verify-ui-full.cjs # 分组 / undefined / 空组名 / 搜索 / 折叠 + 截图 +node tools/verify-ui-full.cjs # 起假 Photon:分组 / undefined / 空组名 / 搜索 / 折叠 + 截图 +node tools/verify-route-map-followup.cjs # 追加轮路线图点数(1 → 9)+ 版本切换重绘 + 截图 ``` 写这类测试时注意:**`cfg.SEARCH.maxCandidates` 缺省会静默返回 0 候选**(`selected.length < undefined` 恒假);**第二轮跑要清 `require.cache` 或换 origin**,否则命中第一轮缓存。 @@ -443,6 +445,25 @@ rgh status # 确认已登录 - 无 Key 时降级为按真实经纬度等距投影(经度按 `cos(lat)` 修正)的示意图。 - 地图服务商只使用合规白名单(高德 / 腾讯 / 百度 / 天地图),不使用 Google / Apple / Mapbox / OSM 直连瓦片。 +**追加轮不能塌成一个点**(用户反馈后修复) + +现象:与 AI 优化改变行程后,第二次「行程路线示意」只剩一个点。根因不在地图,在**多轮数据流**: + +- `runPlan()` 里只有 `!isFollowUp` 才查 `/api/nearby-pois`,末尾又无条件 `state.nearby = nearby`——追加轮 `nearby` 为 `null`,`payload.nearby` 也是 `null`。模型上下文里没有「可点名的地点」,第二版退化成「某片区 + 自行核实」,甚至不再重复加粗点名。 +- `extractPlanPlaces()` 的三类来源里,`nearby`(带真实坐标)空了;加粗词常只剩说明性词,且**不带坐标**;没配高德 Key 时 `resolveRoutePoints()` 把它们全部判为 missing 丢掉 → 只剩「已选目的地」,而用户常常只勾了 1 个 → 图上孤零零一个点。 + +修复是组合拳,缺一不可: + +1. **追加轮沿用 `state.nearby`**:`else if (state.nearby && shortlist.some(c => c.name === state.nearby.area)) nearby = state.nearby;`,`payload.nearby` 继续带上;已选目的地变了才失效。 +2. **提示词约束**:追加轮末条 user 消息要求「上一版加粗点名的具体地点继续写出名称,不要退化成某片区/自行核实;新增地点从 `nearby` / `shortlist` 取真实名并同样加粗」。 +3. **继承上一版坐标**:`extractPlanPlaces(text, inherit)` 多一个继承池,只把「本版正文里仍出现」且**不在否定语境**里的点补回坐标;`push()` 支持「带坐标的同名点覆盖只有名字的加粗词」。 +4. **路线点属于版本**:`planVersions[i]` 存 `routePoints` / `routeMissing`,`#versionSelect` 切换、「回到初版」、载入保存方案都同步重绘;`drawRouteMap()` 改用 `state.routeMissing` 真正显示「有 N 个地点未能定位」。 +5. **底稿取当前版本**:`previous` 从 `state.planText` 改为 `currentPlanText()`,查看历史版时不会基于最后一版调整。 + +否定语境护栏(`删掉|取消|去掉|不再|…` + 名字前 12 字)同时作用于 `nearby` 沿用与继承两条路径:模型按要求在「风险与备选」里写「删掉 XX」时,该地点不会被画回图上。 + +实测(`tools/verify-route-map-followup.cjs`,真实 Chrome):初版 11 点 / 图上 9(抽稀上限);**修复前追加轮 1 点 / 图上 1**;修复后 9 点 / 图上 9;切回第 1 版重绘为 11 点 / 图上 9。 + ### 多轮调整 方案不是一次性的。生成完成后,输出框下方会出现输入条,写一句要求(「第二天改成室内为主」「总预算压到 1200」)点「继续优化」即可迭代。 @@ -451,6 +472,8 @@ rgh status # 确认已登录 - 输出区顶部显示「第 N 版」,右侧「回到初版」可退回第一版做对比。 - 演示模式下规则引擎无法真正理解增量需求,会在开头注明并重新生成通用方案,不会假装已经改好。 - 上一版超过 12000 字会被截断,避免把上下文撑爆;`previous` 只用于多轮消息,不会重复塞进上下文 JSON。 +- 追加轮**沿用第一轮查到的 `nearby` 周边地点**(已选目的地没变时),并把它继续放进上下文 JSON;清空会让第二版失去可点名的地点,路线图塌成一个点。详见上文「路线图 → 追加轮不能塌成一个点」。 +- 每一版都存自己的 `routePoints`,切换版本 / 回到初版 / 载入保存方案时路线图同步重绘;`previous` 用 `currentPlanText()`,保证「正在看哪一版就基于哪一版改」。 ### AI 接口自检 @@ -743,9 +766,12 @@ tripsense/ - **缓存失效的根因(隐蔽)**:`$('#analyzeBtn').addEventListener('click', runAnalyze)` 会把 **Event 对象**传进 `runAnalyze(force)`,而 `Boolean(event)` 恒为 `true` —— 于是每一次点击都是「强制刷新」,缓存永远不命中。改为只认严格的 `force === true`,并把监听器包成 `() => runAnalyze()`。实测:冷启动 12.8s → 第二次 518ms(24 倍),提示条正确显示;点「重新获取最新数据」后提示条正确隐藏。 - **反向地理编码的假地名**:`reverseGeocode` 彻底失败时兜底返回「当前位置」这个**假地名**,拿它去查酒店库必然落空,还会被误判成"该数据源没数据"。新增 `resolveCityName()` 把假地名视为失败并重试一次;数据源层缓存也**不缓存**假地名。 - **缓存分层实测**:`/api/analyze` 层缓存 30 分钟;数据源层(`lib/sources.js`)另有 Photon 分类检索 / 高德 POI / 天气 / AQI / 路网 / 铁路各自的缓存。清空后重建:`discoverCandidates` 首次 8137ms → 第二次 **0ms**;`roadMatrix` 首次 127ms → 第二次 **0ms**。 +- **AI 追加轮路线图只剩一个点**:根因是 `runPlan()` 追加轮把 `state.nearby` 置 `null`(`let nearby = null; if (!isFollowUp) {...查 POI...} state.nearby = nearby;`),`payload.nearby` 随之为空;第二版正文不再点名 nearby 地点,`extractPlanPlaces()` 里带坐标的来源 1 空,加粗词又没坐标,无 Key 时全被 `resolveRoutePoints()` 判为 missing 丢掉 → 只剩 1 个已选目的地。修复:追加轮沿用 nearby + 提示词要求保留加粗点名 + `extractPlanPlaces(text, inherit)` 继承上一版坐标(含否定语境护栏)+ 每个版本存 `routePoints` 并在切版本时重绘 + `previous` 改用 `currentPlanText()`。真实 Chrome 验收:初版 11 点 → 修复前追加轮 1 点 → 修复后 9 点 → 切回初版 11 点。另修两个隐性 bug:`drawRouteMap()` 的 `missing` 是永远为空的局部数组,导致「有 N 个地点未能定位」提示从不显示;「回到初版」未同步 `state.viewing`,正文与版本下拉不跟着变。 预览图见 `preview/preview-step1.png`、`preview/preview-step2.png`、`preview/preview-step2-detail.png`(展开逐日天气 + 对比面板)、`preview/preview-step3.png`、`preview/preview-settings.png`(设置弹窗含高德 Key)、`preview/preview-amap-error.png`(Key 无效时的提示)、`preview/preview-map-fallback.png` 与 `preview/preview-map-price.png`(只含目的地的路线图 + 自定义价位 + 住宿价格缺失说明)。 本轮(缓存 + 路线图修复 + 真实浏览器验收)新增:`preview/preview-diag-route.png`(高德真实底图 + 3 个编号标记 + 折线连线)、`preview/preview-diag-hotel.png`(RollingGo 真实报价 ¥2416/晚)、`preview/preview-cache.png`(缓存命中提示条)。 +追加轮路线图修复新增:`preview/preview-route-followup.png`(修复后的多点行程路线示意 + 完整行程点清单)。 + > 说明:上面这组截图恰好是在 Photon 公共实例全部超时(`stats.fallbackOnly=true`)时拍的,因此 Step 2 顶部能看到降级提示条——这正好是「实时源挂掉时页面怎么表现」的真实记录;天气、AQI、路况仍为实时数据。 diff --git a/lib/planner.js b/lib/planner.js index 554ee07..e7ef1d8 100644 --- a/lib/planner.js +++ b/lib/planner.js @@ -134,7 +134,9 @@ function buildMessages(payload, systemPrompt, extra = {}) { content: `在上一版方案的基础上做如下调整:${extra.followUp || '请自行提升可执行性'}\n\n` + '要求:输出调整后的**完整**方案,保持原有 Markdown 结构(方案总览 / 逐日安排 / 天气与装备 / 交通方案 / 预算粗估 / 出发前清单 / 风险与备选)。' + - '不要只输出改动片段,也不要写「已按要求修改」之类的交代;与上一版冲突的地方直接改掉,并在「风险与备选」里说明取舍。', + '不要只输出改动片段,也不要写「已按要求修改」之类的交代;与上一版冲突的地方直接改掉,并在「风险与备选」里说明取舍。' + + '另外:上一版点名的具体地点(**加粗**的景区、餐馆、场馆等)要继续写出名称,不要退化成「某片区」「自行核实」;' + + '若调整后新增或替换了地点,请从上下文 JSON 的 nearby / shortlist 里取真实名称,并同样用 **加粗** 标出,供行程路线示意定位。', }); } return msgs; diff --git a/preview/preview-route-followup.png b/preview/preview-route-followup.png new file mode 100644 index 0000000000000000000000000000000000000000..8cd04e48a00811c1e6f539f7552f5cdc2e745d7c GIT binary patch literal 34099 zcmdqIQ+#H@(=Ix(ZBK05P9{z!HYT?9#uH3z+Y{TiZQFQbV`u)~`EK^PIp??czBuQ$ z)>_qFtE#)6db%rAQC<=O78my0w{HkiKg521`v&Uw?Hh;}H0YOP8Y?sW+c)HIQewg? z?iuHqa60G*FGKnm5EGbnLI-i!DUg=1lG;il_0a50Vd0@7sal&+%TiJo_Ty2QtK7Xr zz_h~Pu%Bf64bTV!a-#f&1u#+$%(*JV=m~e9=KJ@l`>o`8b+s+E zCt9JS+nH>SGDc zF9Tqg78iJydH>Czz+;v~Wcq0IGfd0%zApo^gz=l;)|1J_)2CWC{cCuN%*-H4C7AxY zj!v2RS?iSE$-&O)%%gtLKl3c2*nE+m2IWvwog2>G3PH@soB5Y{oP3HK!``=7^A_^K z;wPEH!Q$9bFJ<@!O-5|7TO0UXxa#bWRhOD|`vqK&S_(g2s%}5ouNHK7xA>Y)pLjq1 zDQ;;p|KQ%9pQ_SU_Xb(sDdk@>3vyMgb-3^34DO6m8ldx>8eT;|lJ8`>k6+cbD4$%k ziMm@Z*XBOli#r}IEv300Omz8=q9gM|P_I*w_eMSwVSU!;?zxiP||w=Ld}jkVhJwXzi7vRO`irdoyK>H(P{t{&6ujur!TjDH@!6i@R& zbYEL)h(tFE+3<<8TYYpULKB1(Q^jaMG%or~YSw(k?s2wvc63d8SLXJ6O;1m74`Av! zjZYtBdcD#qpnh&>H@1m!&5r8yClw8l3O`GkJFBk%-~}C$p21B9z~1c0Dtr^j?2SMU}bN61d+lmauU# zG6V5GXm;zP-%et^4clveOG5OY=tI(4l+~cXK5{Pwcz?QmlBoqMZE@aM9YZC?lY$ri zYRn9qu~Tey_=}5gGzGE3t-QX%{yZQrFF!7#tE!6do{Bc-Hnx%V702HvATr>zp#B1L zI10kOH{q>Lbv}Y2cz8;ikAyP^??Dxk@HIjBClh_IC6>w_P4pb#m_TV?S&1h=O1A=>J{ zJ9clwSc^4c+W6}n24F6X-AasKn`_nTyuNzinsaD z^eeebVAyIW<9A4L@*sV}1m<*#>Qqe(GybB9RRQuG& zROIV?61ppWQB_98MFaD-1#K3JKL$jdtU-!Ba%wo*cnjKjM_)e1I&Og0SQeYp2Zb-} zq(8q{R9)^>>XuLFzE9=L4$-R`TmD35{cw9a9Y=rc&3Vo(F76bzr;+|L(^NS8!Ea7Y z=OWwVjM&~oik7`V{FVGyKiTKg_4 z?h9GYM6k=BxSpyZVIyZEm)S8oqQoJpqpf~sU?+9?Z(nVZ><;CEUeVgf=E{Nv))U`a zb}zmrl-d2ZSLDV+(NNL<=N?2@rf{|2yxFjnzUvV8U*RmQ#Yff6^B2;s$}aTu_5IQW zigDD}#TS^8O!)Sazm@G}@i*Lc0cj0_NF4j6@upi+KQ?B^Sby>JW4pqYfaU0UNZz}3 zC*09ij`Q&*ys%wvJw)!*+ox|OD}L}HC==Ru(ucQ^@!#~0gg_2^%&T2c;DSpPmdk`& zH&wm^AflKd6J)ETBEWHf@|NWa*61TuP553d91;CeXSI|b{`KqK)$TJ3dy%_&d~cF5 z5;Hl=M_tfb;a&e|xTZ%J#>3)g^*AAqfPB}2Qf%A%S;*jN^s*c3vvZ#-i$(d>(YWbX zIPGT@q{7)By=v=zZSt=Y5FA-OMEOc*^cIJp>thR-$1BzLaTg(md}=QjJ81r81}?dvt7IDN-S=cZz_fT|I$%z{^< z&tGK9M*^oLBU)~1cQfb=!oQBdL-`Fhk`1IHfs#Ms=<|F=rH|2J zK}}#R&qk}&C+&A;R?3!Y+`9MIp+QIl-?#j7lM=xbXK0ey7@iiPwHO7Wkl_ z9}@h(7*IdXfD5HiP`x8J>qwY^`^!ck#%9KwAZ4UvebyyG6LhR4-r*vV365Vqt6J|XZYuK=M)UHhbW ztK0ZT#ZSC8NEEq^D}=qS51+<2t&>TF&;0bQt+oI^78gLkOM^F&gBkdF&1R)!=hJaD z>%E*WRT;IQj0?Jb6-gkP0kZY<;gR_Dh2+p=0#AaVHRE?A=BygMEBS+Lmve5Z=2VW^ z%JtEj{a!mkTd3rYDblZ4EP$9Ehgj}wx$iGtt=jPYt&wg)%IoZA zx$n{6isV&)&hMp@RN%>Fl(jl!*y~p+W*K$W>W3y!FU!9S|t@FEN{> z`7$1>XW4$`Bh7@q-`Yy7 zuY?=^=ho?(+9|?DD~9Hi(GbVtoewXM-gDT4ydx0@%xvBp;z$G~?CRSr^6(4;Z-q7* z*#^u}m#Y__Bh|@^#<<VF$Mw6J}+soa1hgog7Z^prFQ>nlUy}Y+)!CiDzCJ`QQ z$SD0@8r6*m&Iz|X=(0q>Lu&go9PO^|)u$ndVt3|Dcq=Q~ohzSW!E zxUy)uqQYa6o>meOjkifmDo%e?lej&e3`smb8v=08^y)%7^_vu2U!t-e^_#gBvE{LV z|Jkwf3!#|`zo4w7PxJl8{~gM@HsQVegR*K1dYE`{|KPL{oL16*VDk^OT=6cX|3caj z|HqK_{|wcu+f-aq|G5&{(XHnZN~d|G%yj{(q#P9vr08)p%mz z3pVU9?({c+yZS$CJ4JTMX}Qur=90WhvjOwZ1<)`TRIp!ee#)`k(gQVi`!vD0V4D;^ zYL)gGDmZLr7Dxl}HT!tio@i&R;Pnmiv#vtRR%>~b;$5X&1sg2JOZ501cWFMe(ppU- zoq8$jRm(o}(nwd-oePH&ijk@IX>XEA*VB8;{9aS+MLx<%DJxH++_^m`7R(zjdnVZH z`2)T;_D0oztNpW4vL6*VlTt?3;)H6rI#+)a)h@>aiK0jmM|j%pV~xQv+|w_gsogg0 ztCXH3l|)&(y;fixCx(lHq<;7ytj$Mrgh(eer{LM-6TD2`F{rm%xJo)?D?3Hp;n}45 zktXfdxGTe3dkE>#D5vIXGi#NpaH9TO+~`Q8JR%gW=*TIeynx7i4TTBx&TP^l9{$wp zw809-3VL1bf}h`k-9P2aK!@JWO!?ur37jqz5mqxH7rWDCKjG}gR@ zTKrm;mO12W6a;;UKktWYSD$7Q%ZdBz+>#8*UYn=u2fXKQ6)n05#P>PP#d>Gh@yEys z-sC2VA=Oh=X8dd1BgtPlyucgwDzt0eN-X3~co#uOgy@G^X0S=}$`tpsTI10N@~v#A%sX4j z3AkRJ!-aL>^^=WplJ79!FNx&W|IGz(5Muahep(&QYTc%Ns1bISVqYyS>1_HY-JP!U zoU4{)xAdYxRwg#H)XrpY(upL|m#*B>q;{xT%_-J$=<7A&d~ESV=#23I0D=IQseaV6 z+_ylNge{8ThT9*97fcckz+<&_&7?W$(oT#6w}&I0%`VOAxRT0whWXIFKB4AsVL04w1z2Te=|N6y8qviSLDvM@` zO92naXF2DAE)3M)8dtfG%8YL>-tU~uyi@y3HGlK;R7AtDd#tND9r7EjzktlV3obpJ zoO2kk2Me=hRh5rZZ=5W=9^Ncz`MNzIeui}DXoodKet(3e-%qWVk-nt*1IbEkzTt?a z>d8XO$0OA#^heE2H^|}eW^EgZnE?gPrM;UU)pD~T(Cv)hh!^Ra+*&MI>H1(Kvx0CE zLfWzNF(m%PnsexxgBySb8V%5~i=?p|V;?$)jKBtGUMdS?ruls3{HpI3GK33anOu68 z9Hr9DmY01X$XL92S%TDj*7jE9-)8Y{w2lZV4aU;9H#76gCO__VtwWsk&q8wKGMKP@ zi;@s(Zrv;L$xiI)cT#k*Th_CIORl!}<9-iX7E8+MfSZR}d!LJUOis{G3kxFNoZ(D=Lo>;WCI zs7CKWwnHr#i!Ug8)N}JJlY=AZs;D_C4Mh)N=`S_pHz8LRu$OynD*-5?JwF zzSYv`WY~JMkal@GZvWcm*kfjh~C^_c4&z8KpQARGn5vM36!JI!559R(XTgQ*Gl-9E~3rmIpnj9|Vk=3T^)u%-9Fa4&Cr z84_X84wHQSqp=}mc682R@sC%L09lrmlo?{62B<#aZSzh7(?pmx)?Sr2dGo(Vw zf$J&>)$-XKr+$cI@b}Vk-XHHS7ul7R3H0*VG*bwulOB2BuP#QkxIYsWl3m|Nd%C(@ z`@5IdVLjmrn6WQV7+Ftw|LWP^v92Ly&p+xr^Zs#J3=slt4yW;s$&~zi@3jBLUk0kV zU8Z0-wl5jP+0exDg6?#lm}yrfgkm`TR&c*+s~aVrt4iVh%MhJnd*2v+;rl9U9paPC zN?A@+(i^oE87qjN{z0}2x8yP#d%0dpDw&v7ox9(P`EJj`?WtMwH!*{$4nfmGbJo8C zk1!ZE^`Q!Lf#0Uss)t}6<4@&lef4D~+U3INBf4-*@XY#uQM+kmTv-2~gM0>(TDT3W zi7xV!QT?cvR?^|@fF(OR*p+&={;cn)mf>tI;_LycmtYbvTqvx6%NS-0s<`&~1h2Wx z0*00D7s3j3#d}>o(3JDqrC5s^xjqdX8VnLgtx{Txf4ldMvO>X1i=XW`Sx2dOH;`GV z7dm$+55EP*J_)fiYbAVJO1#v*+NH%QG(79nCMeelu%d;#eKe`sic8Y|8=tfr!6{;d z+r^Td@v8A;nwt@8SaLKgLSIL8398xxr>Cbur-9LUvb5kY?E`nvZY4U~iV!k6KjV!6 zZP}}CseBsdYFvO7z8F*_7JDeWklRv4hpI)870$-ZzcGN9rN?}5KsUqlOrNNvuB)jm ziHibAjL%Pu_xtdp5MpJf=KaC1sltj|q zPzZ%;RAjzZUQW#jMexZcovW>u_%Zr75UtmRlEX(0mvx}`NRzrac^tirky!6{M|vTJ zsx2ar6Ifud(8GCn38#aXmzOQgITZO~M{I%Ztv9ufc_x88HuF~ZIX;DMbMh|nP{j2r zS|U>$E^Bit`2228G%H3ehgYFze1nSHn-VRv2pJo!oXP8SC$eHj_7q8_Fw0a5$IY2!c>@^9da%!9Bb)`|A&n`QKp=pe!p>drffmG4tbkM7=q;qurc)Z|XA zy`}y9AbL{p3jv3T_If|Jpi<@LAeFHS& z^CFH!w}z{jYg8vbQbj3I!`U?0Z#rClAr4-k;_psdq2RrDru>B?^?dO_WhJ#5q~cMg zz}OD(MZoV6mU=6^lwAy%(JcGrcBy$!pw1#u$bWbkgSwiDxMDViYJWY@5CB#oQ%^lCMSLboa6XXE8aZfL+z z;t9p=;M+FFu6dW~Wfx+k(qiP9-e7r_fGSIe&Y2|*ZDPnVaay@0N@f5aSW|Z_agzFv zSKa1 z)qI538H@;3@dh8(46MQ6UevHJC5ZD(m!4`aHP^Oo+O%=4S%im4ZvBpfdYtot-CR9T zPp)wt={y|62#D4|3={&3&0Ae>3V{9&f84!|yF&Im>+6MjC;P8UjrnlJj=TM9x9!hD zylf=x3SPr09L=@V4q;?%J3%RJ{teUZt#Wu@A27G{%ynP|wB+J=rzE}I+!^j_ys*MS zB{gXgLCjh6)v^kMJ0u`kxz{z+``Ba9Am z#t+SzBrx)x9^S`0BLz|hIe0-dj&qWea9LQ7BQ!cF9E;D2zj=&P{YSuaiSK%I3T7VX zxqIY4;!!c>kS_Mhtxf`pic%~l5-J5->~ik|jOgJiG!DDCuh{)7*LZpAHm*;>GjB37 zGc$z6RJ}BB?g9Yx=fgQ0@J6ADZ=>Z8Z9|?03o4olV)OzbRh#(iwlb;f>hd#0%5!`! zk2j?=b?mzog>*Ia;9lO(0mJcKMkIVjYQc|_H5AW#@k^d^4W1I*14MMJYueUFKZ&#P zW!Vxwrn69=-~~=f@#KtCoCfd5UJ%FhOdXU(yZN~Ig?@be-^mPEujP6Iwj9S1MnHbk zomNKnt;A!kin!j4in)baNbwH_IeLq{`s&o`W!U;~*9I3@ojc)E^sb^<2@@XXQP+IM z$Y+D=A4LIB6cu61yaJ-_Qjw-=#~N`R{Hwuf7A6@l>%TsbjVKy|p4+aQ8MB(aW5+8 zCmr!`eMXb~>!NCj_(%n0wfRte%E$!CC*-M58oDFA&@$7_?x4=%MEJx<=%6I0c5a)9 z9z)N_aiu^qsOd;@<>rR19F&!KqBPshMcr+eTiN5G*b?>nb2|N5A4l%neg+jXEa223 z3WqO=5h907!<6;-=TB};c-iNZUgQ;`TTNK_X$V=w)&g85;-_Bs1)ucNh0#(9k@xM#crUyFRr}pv?*`eu*PLkat=+T$Y`CEl?@|(#6fBwqE?m%;c z8^m2fQkc|nHWka0rKBH*il)+PILc?1V_Wb~>87?nfjUDU0xJ^$IQ1|nSy^r_V<)QT z&!da$Zb~a;?AsjS>;tOEacg&5O=H`nZB{lp_w+beyoeG`e&vs%-+Ww)ZU(2NQ67VXuf^LR2%)B-#WrI(Nqvp$GNkMO8zbw&C?*Q;xoM@K%Fy46!bz}VaOuow{u zQfRcyQf|%MDGEAPfrM~ETPF3a%XlXzJG}f(YhH8Ld{9-P09gJ{9NZ>7y`eHktf-5o zX{YhF@U76F-}~eeN=YnC*PBibxfJ~dRE`+k!(7EVX4I=s%jg`$AEIGZ__Fef45ZeN z6DbkA&N`@gRrOv8z?MuYF(F2$Je6C?E7d>xD^^1LDj0eNP^d>xD4OC<+|PO0$y-*U zcWs2{M<>KJ%?82^s~r|;!yi&;xs~cb#DhR(}p61e|Yle z9r1H6DfmdVlELoXN3@P0I$3>ydU+1vo!v%yRb^%1=T1$D3URSSaykc`(55h28Dy3D!~5w9o|mi(^~gL-Q#gXwaGQf%WLd$m`2rf$T}C$ z5g+drpv@D>J6m7EmpWEA?G=n*e%dhKe(p!!Jhu9M{D)P3oeT$w>hm-EzRf^--`G~aBumdNhxfGbY+ZwP5<6L`{`te5(?L=OXL^Huy3MG7>w^$4DmKv7bX4RhT!$9a%ex-tRy?# zJ-|x7U#Xpe&8vg#qdYAJdnvHkqX(IdsHyoJxPB1aw1ltArse&e8la4yKKOOw&GwjM zj;4Bkr>Oj*-N5NnUXz8Mrvz@M*7X+FY)3pdmRzBb)8i4cE&)Y>Qy;5PPI!@-oAXOt zPWaNl6NqR+g)me<{5jxt39KcGraUh zN43Ts1?R$czs>EbX^`f@f@oHJ@(?=xOs8=SugLij=xuA|ynR4!XuE#-%YGMWulf%_ zaj8*b2d5vKp`xGo-{7v~_#*;N&nyX^T@GUV<4(6GUu@MRCOH;DH~U>YiDKTpF1fkx z*ad1+#3Prhv^1yLiD_gC%+;D+Kx_?6U8(Ctx{)QWyuH^Rcs|mEdm@BU#DB`PiFYMc z|GTJUg4MREw?)MRoPD5$8nXM-De7V7j~3RpQ^_pzBQ9E9L8F||lA+ScjBS3fzJPbB zx%?Z&7x{1s?>f}CWY&j_c50I@+jV+Y@Fapa<^8#nhqh$D!!Z#rT4TOHqGoNf;DoVK!ntR!Poz!e9UH^RJ@5wmXyBi3ce9$~ zlG9h*gv(8Yk|Xr+Sy=fB^$gC1 zgd}M*4Mn;)(_Eq(y~fLakdK7Fmr9}3ahp%OH9%VwAQ>lR{5(Cwg?onU$_=*>p8u{K zUVm{O?3%Ne%XUXzC%EV|>{TGXY$p$cu3mtZqq=D}Q8}I8Id)1d4N0my{V_ZeLa)nx&*Q*)A- z%BNPFAa=Og7jt&cqA^$f>9mB9-(LE2Bz^V$$n)s~c_|Fzz?Nest+5 z&hi2;Hza&5VIlNn*Q0qF$mZr<4bHuH~Y3JJ5=#O4YqD&nZu4y)Z%V{rS~yq<}v zAciIp6(FPR#(*Lv^eEwHeNR}|}zDDl#wTbB^JxS0{Opp_0qrYb1p zV5n`Y3_j(pM}?eV=-; zvM+qS=A%9?3kZ=oH8^Geze?PLpYiXj=_o_$oQnzZ6YeC4hGAk^r{3YHNTcN;erv_7 z`?HY?lylLgd7^3NdfnV)gT9+m8_xJHCz@%6A4Phk4u{=(CAkNC!I?;D9Ja#5J<@5s zUlpM>NYeJPGca=4Tn?FfA?W0qly81?UR`G8@cOW8`(?A>C}JK5mA#yrmd>WOs-v@* zdiCjzc7?^Z^T|XL-L&6TA9$r%6@gvTSweQ!&lNI=IOkis7OQJ;ip9bx0P=osvkZWj znbQl?sHg72E~7W1od&~H)Gm?wTnY%J`$eBT$~j$jJPp&5z{E=XG6N`r@&dc^u~HpB ztY*{2X%_db&g{gWKc^-~-cup+WLL2jF=$CP2{qyv7qWTmvl30q(KY}IO2{FeN*pX$ z+=TzhuE{-R*|0I7^yx&1SX{&)*l1>S`~_O$MugAfAtE+i4Z=?lS(w;SWah&oH49_a zw>(3eURp61;Sjyz&5~f3A_AGYZk6**`E>HAsH5wPtji4z%zFiY@b+DRGKG+rO1=no zG`csxm$IE5H4WDnk=7U-({z#VLlx9wubDxJDlHwAZn-J1ucn>m7n#`PEFSLg{CQ}i znaDdR4Vkud?rB2xyxbaJxC{^%7bBW4AX;$qb0ESOHgjg+Pm9$rGH0AL7(k6=a8vZc zO^SfF{}{YyX+uD)S*4S7u|EuzWc{&Rq#s$Vl#!7^{ww*+IL-UT=Q=ImURh6Pc)aAO zj!DwAK&U4`@?MQ@8`EfUd>H{3NJePrf@B0d_5xB@LZcfbv5xEwR&gA3_-7}lqV{e& z4s9ohrpgBgL)XQtVP5dPMftKW8Y~Pyauh~fM7^=x&jZT8ys&=LD|wIEYMRELkR!a8*-u)I3svWN)F7tlr|E?%kdgI1FWtA;R=iK% zCJ_S7E8h7j;iRUIDe2-2EsPs_do~ocM!mQ7N;l57J>w}qvaFI574JSC6b{yR5h(}B zAalJt?O!quUocx$dh>XzZ3`5r&FpB8n6e)U%d|Zbrwl`YVVVPxs|3gorx=tyvaqd$ z7t>hz&!%SKLxFq5I(vtMCQVZfWdf33=0xYc*a1cM#`#Bik0R+Fhoh(SNe93mGJ;Bl zH!x9zXv;&-n^ulWehqi2UABXjO0>pg8k`_Jv9C0H)v)X6hN33|hOf!M5@4p8)b(17 z7HA9H%cMZJ{tohuPgX$cWVy_JXKFsysF-?+?UckJp98#3dZq?C)dUU}MeyVui&KV1 zX0H17JnW$p#iqYcQi6PDUT8%r(nKcx63B#r>1axDV`F06+3j`hF7o#Ur!tNUjAc=i zbW%x`%GKK$Y``CzKNJH_*;zagZO-YUHeW0xm6&iw^gOXMVxbvF#8 z4^AZ@PR{Xf=0%D7#RCfltd1b?G8shKS-8!bZoigS!^A;HQK#6-ml?3d(S*n+^3w+Q z8|qvBRl^++YMpC!$c$i&u@xhPSms^#F&KyB6&u-^W3R{P1&u;nrH?kUoSA-X380jh z@e~|h{l&%%D3oqzlN(d%?vj!VofcrPA7fJ1Ywi@*nOoI_8*VToVo&TQiT1q18%M@Q ze~<4u*q`h6J}v-H6~SHZhYYhn!J8YYY;=x6Q!U!09>x^@HWF%#v&s5|T9MQZU6kj} zrn4|=tiYkBc%5$i|URo+|NZ|jy zT?ZB)TJNeA13&YYQjA)0Dy!&)byz;ZaGyK^Q5Xo+)8p`L+}*+XhhEt4Y|<`^ z#67v?N82{wWOMY-!H^proM{W_*q((LFtVdRj+NC_H8s^eiWKiq?^=J#U8dr` z7o(-!vdfl?nXB*Q%_#F@$S2XVK4b4MhjGB_d*R^cxrrlmHR^^JC!0CMW~e+rg+biE zy(ode9c%^cHZWat*ZoEvXrZ($YQAOo`y%w>Gs9skzu3Cez@Er1c2FKLcm72Qxi$1y zG0zuPjwq$R|GXOac6@9y{T%msXT|i6;Tcdk_fEpND^*=^a%&XQ zxS8m5OYgFAvGLH=|1A&u8!CdrQ0J|IIBa2HRFD6~mUGnu!jjCD^#b-`x z2VXd>IIDO;TsV96i;aE@o_!my9UmwGu1YG$Th2{mXiv=#R0`S4fui z-P(r-wd3GLk&FzPQI{vb^a2S{HrXX|++$+;pq0zuE6Z8z4z!+3T3Me-=-Z(~jHP%3nf&UC87CnbZ?5CbUCMTL6sap1t%)9_fZs9iV++erT6$H++B;i|B+p&% z-Ykp8V_@OBdm`k6t8vifHP?~&je=gt4{VQO8MsRF6LKL1iQ~BfUcvh6Jf8v^pY`Sv zf{A|6Nn+7;W-TTQN!!Lgz@*FIGmY&av+)g=lU%vk=MC>!n)BiOSAE;?iC3=EA^P>Y zE?^*D^f=%|>Q#;AK^ixByjrEVG3^CDtGrli%sybY)0zsarQ!A|METSF5}p3zLx-h}!fhmB00Ey= zJNeqtAXrmyS{_nlo|>{rk~7y;lz_iSz&qSyu;DM9x2kGm5L#mh1QO?dx4YcBGJZr~ zOd8LJA7CD zsAI~Ya7L!e17?cd7JL(6{AX6=A(il#tfCd%Erd;_w{uEd6SX$d&@?9n+hF^|*NI3A z$<3W>lluAMJZ3fn#$~_2VTA_e?)C+vI+hCBF2cs6Kn<{%tSg&E(9}4lsf}&%beo%; z*DSWmIAp4TkYi7^%2t_!A`PScO0zkI67ZlWfN(r^hMsmOY+3HLWj}R{gqZwxr5kFh zTi;~eq1tu(vQgn8eU(0}(8l_U-&AOD^`s>-B=`N!qF;i+-8erDDCd_n4d*H3obYZ+ z5!kD)AJzdFehnGJszVc|1k%)(a`JJJ{FQp?k{1pabUxBA8I_q|O-VawFP{2Fju>Eh z^-45pen+Sw@O!~{jCgMCnIi4D-ARHJ-sJZ6ag+Q_PvL5#R%rL*1l5v8!B_txkclQ+ z$k$DGtoY{cc6Q0K#mARjUyfij{kezQeCLaup1@%|+QvzCeQQ}4&mXcYs1f^o++Eqd zzPpK?huG@h0J4jTVDm>c7H z@$kP=)d4_eI4s7<4m{C30wTTTC4 zzbrklb``aAkt*Cy9w#LfT8qx4u8J{cs%v}0<5u-uRN7(+aSN*-**%uU`Rv}o9;^)n z{U3%xm)EDp;B06a3wHMFV{v-6_mH9|A0*@|hvz zK2OX$WJwx+3SK{raWhrE8^655=^-V^(`;{Jyve&>1&sEt>y~YO+C8M=PC?Acbzr4N zo9$j#;W1&|#fv6No&e<2PTL%XQH(*154oosvcHnANf3k??5&e-3S=IoGe8D)_HGjYAO<#hO#82N&Q*WM@MJEpDT>piUd- z3S&IyH2zl7M_;1}%Q;|&4v35~3fyPfD{AJh>0_3wbt{GPP20**J!*`!(8CF|lzUNX zVkSmVA8K9I0zd@;O6VsAHOg6yRQ3Ai`4fqM z@<2h6s$Nc)RXPMrMyp|NPgfk2^dZ>l->)3@<}7z7@lW~RTM++OQ=wcq<}?i4f11Mx z|F`ua{x9DyKL2-IhvFr#t=KWvAdXE!o}FiIhZM!c7Lr* zJHi*%*fvb7o-845x~CzhrbKFd)Ty>H$D{l;7P~*&`zwc`{i8PHTM~e0M|~XYjr!?V z#Nq-NWR-8Ek7YuYAIHcXX{|KHRXdQE7NqN>mwLmyQ(dHkqcKnkQY@)f1--1zQTgga z=-Y>|V1U`=xS5Iit8BU7i9@(dZb{?JH!&Pn$2t=`P4GIQFou@`7SOUAyx84Ib>}WH zWy}>w?P}rywV1L#NVAU0p3bD~a^KF0ON~x{)(YLVW4Dv^9MqE`X{J;q=j0j@CREeqsoi#DJIl*Wa z&*^i{;?RCzR-P>O?K_n{g^flX#5!Ept0gAtcp3i*zf2Ix9StrVS)2UHUk(3T0fMcZ zw+T90J0=-v0^#$I4`Y-}b|ag%%}=e-I;`I>QD>c+)ivcGP9t2>&InCTx@cd zAviYzgxgdui6%-iFKtI{%Z`sNQ;Sris~ex_u{qpU>5Mujv}WSKHI^8duy6f9y0}M_ zl=svvWnYSl42VK6ZxhcY)sIrNt*m(~rr6AidxLLwgJ4OcE0;-`A?nYK`*pM~Xhx-5 z@BhsD4ykC0^NwiaOVJbGd$G!Be5xoqjb4qg+9dhj;isxu4Al=4IK!~9RFVmo9Y3Q9 z>1Z}kX$*SaqWu1&Jp#VzbU;~~o#0_d7kq)&bX9mOLt)K}iEAHdrcIZgqweXn=bw>V zvH_z!Y+Da;l!iSQVED|x`|UMi1%)5s^REWhgz4O3^zi6R=26Z`+2Dk3hGs6J+YFwe zPEXy#P=G{48m9FxF_Z9xb^Yh>{+*_^_K(9E`Qcg64g*dOdlv;Ld4;$+I zRC^jQ@#JTMT6GWA(W@bx;|SLbHX{vZ4n%;c6`FbWXEA-IG*4Nc5CR@CW;4;>U=T;8Y$-}zJ+xE)KCb-BA zAQ%=F5Nsj>0X*d%(FKM5=iJ__iv;Hu%8`Zz;@%Og5zZ!m>2WG?;f%QLiPZZhN!h>S zX-3daty7z?V8s}x(V4erkYhUuMuuP3IE$os4X=_sF7+#ybh)xdPrfNG)0scDA{GuM zrBv?^*jGeqUO+k>^EHQpRwN=&-IO`p)O+2(?fc1<_lcUX{^^2(pvO+>^>{OVTOK7B zvl}>FrLD|CC(^Z~#1TMypvHt6NxC8=Dl)MT8hTEaOgV6`bv7BS&Q5RnR)>gIb->^F zGcCX<9~m{u;zp;n<*?XH?0G~`3q!<-U0*Q~?D!K>ZYs})XHSXT!c;D1ikmsf-yDNW zJJ-`p#$XWhierx-U_B~qdUVj00Qif+FD_mA2M9r^&E3GaUTr876e-nhp01^JbKXD) zt!|t#+T8%e_~|ALBr#87CE*Dvf|p4#E3)QJUox`|QQU!k4Me+vp7FVlm*F%tm*@9I z6Z$?X$<+PlUfE>`|%S z|IG!=hnr+0T5XXoh2+i?&1kcxeOop(<#}A65#n@$DL3p7x`7u6^%V6-Iixcu4{bCd z`>?EKH^e>nC|*&k^5#$*KTtgd=mQM(9fO^yjVs8_P8*lc@kEtb%@n0;2OncljFkab4F z+#osw(*P1|yxDTA*f>IpG+Nl5k=wp76BdI zx~F7djqgb+Q^+qe#ZS_~O(b_EJAA6fZ1`Dz1{3PN&a*yl7F@dqK>t0Y#-JBySvIxt zwAP?UAw#BTCrF3${V@gYb3`ukz)JGGW$uHTxJ0O_lh2Kc!{y?SjBUer0SE_Z-F;Cb zMa!h(Aw<%-92X9uZv0zME*2r`LMDZkdapt9iPe&_R7Ikkou~}O$ploin(6bWmACxx zHzowZquwr|+}sf>?B^MD6TcDKE8zj%7@^2^?t;Z0BufrT(bhq8 z$o0h1TGf9N3Q+<+)N+F(F>a$O!42Q%FVGxk31{*svEo=jMBjeE0C>BHZli5IK+F5V z1d>`>MEC{Q2O`Cw#74P#X65Vnbmse7Z3KQYnZ*!2l2Ns$_u@0f^QIiDK8Rkr0#vu>-=MpA#cWpN+>+CSq3Z?56CVKHe^=Gek>crzovF zwkL@%Yl_eNL!9+&J2$1;A_FHM{!VcyIW)5E*kn-9vR@b()#pQ8ijr1ip0v%tgEX)tyWP4A#d%p?s<(^|w)&221kn{vXx7Ra9M3vo(mjySux)yIUZT;K7|BLC-;h1rHwF-Ge*9-QC>} ze(*lu?Y{l?kI_B)zYqPm*Vr=F+O=2BnpHJ-v`fj~^RQSs<-Ztek_?9*gjN_%oqlq*E{d(%2$f?vkQ7rB7FDcHUMw#wu!6rT zO(CTfFved^v2!<@bl;O2bOnY&2Bhv>nW}G|Him?qpVH3_K}us!X?} zZWtFN!bd}GLg^!`<%uq2H9{Mb{5uq)6hS;0PT}2ylsahf$}+t@{A-pPJ(?IvW&mT6 zF5NAUN6FO8VOHE`iLx@QsNA zLt(Iab%##dFQhn=T6R+|Hmqr~8L0@pMB3B^ftj~@S4-Zd5AAT(e@0_r9$y!OdyA~v ziq!Pof9SPPim4xx=yf!3m9Vx@$>+n$OpIWXvIId`p|9=dgB-WkLO5PK=7!=c+HoER zpwKe07;I6g{P*AQ{~VKIVHZE3WQyI+V!R3tTo{NptIwkvcS}g=`y>XV0edCs^uzsj z_zAb$#_hjxen_V()AMzW-gA8;d4~G^WYN2hA$IJYSF5CrlyOr2`(QM?WN~2ULWOK& z;VR1btlpQ=hz&W7U83#Ze5vcN)%w$AqvGG#osnvUzMWf+jtUs%OztplDaeP|ykf|K zMZ#thBI-_seueTJu|v;3>vq$`q>XDyC!K;o>pVlYydt@@uH)!~X5F=lF$1a%4@Nc_ z@9&P+wdw4Rj6CwNxhwJQ4E-S~iG?8mT3VsDuEn|pUIq_o#WU_8Ev^Kg!AyfSG{2rh zW9;tc0;Z=v`0z%qH%AVh5TX+ztb~HH(l@+jvvYu^**7Jbb`^0^2~M{@9$-Np=y*PZ z51n`NXZoncN&ihGEQ2S_$5_g5*u`@Ankj10{ICaMk4WDIi=kDV;`&ii(bbU>oIaH_ z>j5wwvjT!Hk_glD&>14hb%t0L6jGROTP&)e)z9mPbDaD9pd{F>bI$|fa+I2gjOGet zUb5e|2kPxH94LI|$ayg}t~#;Or-4>FsY+Ya1SGyo<_y)^Ygcw9&LB;R&L~&GH+n9{ zT1O3?IKGJCx&b6BqABl`=UwuAlYG@DxN&TB@A!0zpoi^gjg>rc5!~Zq`VoqapCE(< zHN#7uoZ~m^w++kf4VZ^Zhge%&U2L_BV77W_xIIC(F=*6F3H62cFCP@wao` zw>hfuvQEytM8)j z-gp~dusylw*m5eEF|8PJtwK1);gH)R@cyYr!v)|NKk7>hlAC4v{o_rR)HN zXXx)SgM2Lesj)_rV9orKkbq7^EyoN&M-O{i8@Bap;6rys6Vg! zfA@-C!T!ILm-=7PI@2_V!6_R2&gbmOJT>unc>y~J0Y`2xNm-qmAN5|X;;K!8&kC9r z!&8E+VIK$-Gf$*|)RG-sg8ZFs(TH?4Q(&;)z~eAjbyN=b_*e1t?;LH-=TQ@G1O~Xp zEO$YnqD*swPID9IoRrR#NwsKQ8R9g;Zp;;)a1aH0t53thwE9>j;4z7^S++n=)vqM2 znZgObTP)$x4Rr+FYEtFqtJLN6;N%y5_h=NhZ_v;F=*&@S?>|0{HVn_#h z^)y)r5mvv86za{*!uNU?Zi_7x#E(?JKPXEr?Q;L(Yrm{iGO2h?A~u~<5J;xd4gD^X zS=sLNjd;xcFT6V%;8Qqos=7=lc8=E(IOV-J*~LpIY!Uy3x2vr*Ji$)2VF6*PHqbAx z^APFwkQlZmmHUW-*oRvBl{1*XuNj9}=8gshl}<}s5c%>-H*p@^$1FE!z*k%uJTuft1#%pOk(*SZ2R-3kEikq8>U1MKHd~Qtm>wb5Uiy4D|uocuqC}fCE7zYaf(P#CJj%c$gK1%rI z^Qxa+r=Ip;<<7C8B<~eOTLkzd;xdtYavP_%{DaLS)uU(gBV+Z>9(tO4%pI9NxUSVO{3^G`#7LQFQFDg4GeP@(=9R3NxJ`xqlclqEamk2pNCG zK8}SeVgGrjKkYtta>W?y{=D(~_YG|29=wEFU$Ymh?eYt+!0h*^>Dy# z#o?$^HmvJ$Y5&DNbogm2pHP})mD*@~3i_Sy$Ur`nW`}cmuw!JV@R=ln8+2)GvOiT9 z!_VLSpeMkfPHfz2mxIwXY8;Bnc9fdkbbQR($;-bpw;;wS6xhr<*e!UqqvM_3re7VY zCJSe72Tf6Xyx=%)sMPMT#WI=;I3Q+aZ@WJiMM-Uuo1jc-hFdi{959)apCABCWLmer z-;d3fW=y7jGv#MOaXO(-VPx+p{=6T~?F3hc_V)hsW#2@XaS@7E<%*&9E~X9p9KrF| zRmp~Rc~g<$sHOnV7>qNC$<1$-JEV9ZC4ZS^=_bGuVkkNy@~wQ$KAu=9w5$?s z?|_+I#EtxWB)J;KZ_KauYgA6W6J2pXf?Ro5cX?yR>eLwI%N_n`&vCgo&f zA1F$)D5V?XGOUZRZB8UdyRIa7Ikv5qhH|;6OdXAt`v{v+>{8y~ElKeuBUJbZZZ+G&KSgjli1oZ`N4%Z;ZAK)mqfq7}>a)1WQ^{k3Lf-LMAnYr@ zDv>^LjI6=fhj$V;+c)dGW=x@$g|uhk&zV`c7C)m=9sWl6vDSAgLQdUyioRM>v5GIE zKAJp^lx4R)L@tqCU}M_35?WKdca$vHmq@iG%lCSp3J~^zC}k$%A`?ZeQZ*d4Es(Vs zupyo~QkVQ`tp6l$OdRH$DplX>FD?W26F>Nvb0Z|u%K4HvB7h!ZORc`R?X&%fkM_au z-f8h&t_$c?D+ZSbEkw|6X$H9{1u>51O~57h{!S)2JT^_UUOXqJ6{mDO(W2?0Mygc; zZpw+?(XO>p&=kC&2O@a!wqSSzrv4B@QXW#2g?p~~7n)5{^~Va~YEvD;#lU-pA$Rvn zs8GFL=Ko^F8k=UzF(fDwk5M#gRjf3`G_R5UT%E=kycH?^Do2KW>1!Z{WH?yaO5A4u z$6rD#uH8 z+D*ce7vJjh7j|=*gMn)a&dIZ+x0$}vJ8$m1~Qc%~9K$l&7(?VkNe=sosHprIDW^tdf<*P@m29xH%u= z82`|6-#=Pzy|yeSYmEWM6p(+^Jb`@Q6xro;qO>x3)Ml@-QO zvGI(@BT5ECd>Jkjt-c<6E}G<~0Hh3It>)$LT6T;`A_~mMp;m)FJb9_k)mVV*Fe<7ZM<%oinNzfslv!Gir$1}6 z|M@`SI#9hpJ^TAc0)(Ha4%VXhSE}zbo;BF^<=LQ9qIB3j!km7M`K!1ebAw5HNK|x@ zT7BlpiGqG0-S`u|p$(xgVZ)cT-4@kS($=`Xl&{tuL4?VL$Lo zk62X6TVv6qj8{!r7P<*a6p zL8yt?uOpYGDxJg|;I4v)lC&IhMEdHk;3fYeng9JhzOZ4xTBbqFyg|`FMTw$kh+NQ% zgtVJK6hv`73x^1 z>nToevt!Xg33nL6ilp}%!?1>yDZyLqa44`r<&~KWAsU%n|E5|T9So;-+^)B{{mdWJ zaziWb)0b_;^A_2&tYSVH4-B@&ZMiyd#2m6q@TnWQj^5{0{<@L!RdnQABX(%jv zdlBGo4^Qba#gPg`2Q{^ohW_YFc4rw~)Ctp&n1_~T+J3XdXmSm^$kKPOnlmVfz)LGk zrTpwV(@miC=XBG$1jFpLa*BMD`e+uTW&>x?S*@#6gY7h|0AFh;9nPeRDseX<5jWD> z_Nb@jlb0 z2L0(q?V&T1*cs6?xoSjh_$YyT)zGDDqW3N?BwU7Eqn*~SpYHQjAA$nc^>$6mu$Yw@ z(k0y(&0ozF`W=r3rjLUJBGAA+eceWq#@4kAGaW4iNo31%BegE|R0bLIOq+=RE6 z2?7=qe?Mi`aN>t_kgFAcQAn{x%Zy3EwQ3>G!3z!|zhf43yObTP~g(CvZHLYc84nj{G+J=Snk3RM_pV=J#c(H!3jYnnNnDdHyGW-y%h%jFq{lFp z-Fr>v(2hnFTf@0uID#?GH=#R}4j6lcIf-2t~{?Uk{& ze@set=sxtrHzU0!kAR{@$@`U(O1v6Kj4|m=2kj0iit7gOEaewezS=~pjavNMOBjP5 zQ-o>Zl9d}Jxb_MwHZx+lG{6F7eidX&Nj&jTrecH=?i)A1!#EEOAG?8>Vg`R|IS1~4CT!>1*yetO@t-0Cto_SiYTN(6u0kNWow@GE zwT+XL)5e4$$Q1AfQ5A*E%2E2i>OoR^O`Ba(Q%voz%(jZv9S~oBfNX4*cF!>(Cly9c z>);))<_L~*n7DbJxS57O{GXSq{{jU{18Q`a0-tgIGh#==_n~2MAMmF4qYh_*@INCu ze79J9;72R`+^)(0x+nt&qT^fPQ2p1dcBq5%Kcy1$wv*Jht^p2br|0LZ|5;}^yKfMo z-s*Oru`Xl7|ESPWsYhE=1XowIADC^${^wW;w?G(`%ex2isGki^|8)jdCMwB)&F6nk zqWXW(;Ntyqa)51SON)~m6IFT@pI`48H8R<1T9)%<{Q^FEFUZM;H+F|^<&P%ckWd3| zZ!Z?BSlNG9S6q%N(=7?7)kbQs z1k%5MpPm$JOuJ_)mI~q??r5z0l3o7@R8e|w2dy=`e0b!+J7Si`O3>~wn?O)jfZ&lWwR z%;#+dzCE-4HW4j<3Uj6;d&RR$TyOJ49c6JkdziLSWCys6z+!sn+_0A0R>kaAw*KasxR%p(S7~HPGQBbteYxENS+9b0V%Dw*FXSrFjWi! zO6^6f+kuMwfc(MQ(k52pYPV@urWY8At;OSPbzmhuEwWWHH9PQo&)f6ubB>44ea5}y zyFnvxqa5R6ZOnOn@Uq%BWfc0}`1Mjp%hX`<-9PRs5PB*+j`;rEohm8}h(mnsZ$BEK zUuE(qu}$X=9}aes5Wjab8mTs1(Muz+sE56P(IUbo8yJOaCSiu2l$_k#*LF{-tE>C^ z_*U5c-u5<8XVL7uV0dUki}a0~sYg8`-X@sRzqTRIrTgRk&TA_trJ9q41z{(fs0#0WDh70K%k;#mi!pV}QeTvX&Ys!3w7WluuKdce5P?Oi(W zhl&xEj;ZZ)uYAE6H?GO&dd+gD!RE5EGGbAVhkqlV?)FCK_X)EDG#b@UM;GQlQC0$X z_a;cM+OH&5jC)Tel+!*&N^)l6KD+^oh59CK{!b{{Jp~)oL``hgF6bt4*qJ-3M3^n>W{sw@1Iq9qhMdiZxzD(_k|FVjx=e z(C{r~Q1*m`q_ldX7+CUOpx;HVK&An2@Td}ijyHs7RRE8{ZOawaJBfyi^`tNPZVUnj z(x%SFFYfR4rqY$(!|}a)7L2pgb=2QXv+2Ix?$?$?Xn{^oPk}ENUf-O6LVlM4TYxFR zE1&z)u{JHc64!^$R}r64K4<;huIqpt=n-v1#Bk72A62vcad=l3?Dkd{;3e=umdMOU zg@s6E{inNVrufShZ;9ps5dJI&HEv&Q4;Ha&Kb1NS6@)07&IKehrbSu()l?FQZaEZ2 zQ2lm2v~X%4|3Uu&Y%fVYYA8@_ZSnYIs|-;J_0$UdgQ74$iL`xma7YmhFQ~6iy9=n< zK0mj#v1!u?hP83~Q&~AxW-P-jv?vAB`QKc?cbLL?7)+PPOF*VU*Wc|4eTFW%sp%PQ zcuMtmvRI^mc-XKAZlqcdSn%m=k;>)X!C2~5yy!o&9++PN01R{Y+refyOd^4>&;hS4 zK)}uZ$NT+5H?0X$&RtY~DQqy+XGHiGWOHdG5}op`90^#|%S&fx=W5m81g71#eu24-dYkSYLPKT^MJ9!=cv$IPP=AuBr_Gi%^JF6l1p_}?(y&xR1`DQSNG#*U%89o|kIJ&=YFYXcWT!u~6Q~NqV zD7E5M|jez(7HX^>r0H%Mexr6x}@) z=57G4X^wzt^E^X=N8-4-+1JG~bGh1V6r-N6+&^k|-W-2rLmg+B@kLURrSje0dfmog zK^BI%e4_0J8|tlB@3S1cfZ+Awsqg2rW%m=lH%>EE3w|zSLO$-=;{bBXPt>tBGAPGuOOgb->>$W5a4ng3O*q;%ln@@VNc%c5m%)407r-SqO zv536fd~FXPDg~86W6G~buaAkFf{79(Y-u@W$E=htR~8u^Yt85if22Did4QcBksOV6 z4!8PmrgGkgSf5(m|Fi;@T)6TQK2+Z^=b>BFkAPyBBxuD@lAmHBn;RVbGEP*$o5RSY zB%$W<@Ye=(|5-aREH6{W!X`4WKLh*~kC%?5x>Y z6|vcST1pbMnd{S8(+?<6jCvF?va~48(LU^@8ClU zD!v*P@_Sf+_N<`z;dRsnzaR;c#|UkqrjxK6PEOQm_RcKvS=C7`NS>tuY=|7zv?dhQY(A;b$SZ^m@Hsy(o9VJ_mqjtm(Vq4WZv9 zz^-$_&F}Kwol{hNRZoX436xDv4v+tGr6W-ZuU*hDLgCAWt)jY0^!N?SYj5(xDIoWy zf7j@775b6H95rD$d)U<)Slb`JeQY!J27X1Ixmars@Y|83c89>g|9p4W5Zt~3K8PXA z@gO?3Ld@PtS&4mYcKKVfZ9U5vAR#&pTpfOJ@%O%P^a13APp}$7Oigmc^Yp1{``@Hy z8pF$qjUwQFLAuQiTx-78bR~4(eMQCn`baz{_lYV}fhGvizpQ??z|U(Ms=NE?M%;{s zID{|vV=o8L7V>}_*1h1c_~AFx4JPe)*{px{pIE#&xwG)l4IS1-2%)lGH~5A!`F?yI z5q`~%s*(Mgyg)5!YFdksYnFoyM1dn(Jl|-Sbe4&BB-_N&1(axfh_Yc>*Ln&@tmFJB z{hinyO{yh7pu&-AZUwwN+@CE;!^r2^F?+C}c~u> zo(i0Mcn?U-A>z||gTp<+kMZV4eHWs^YRx*atLg3yH9YFN7WtPisj)WbguKbCXR4L- z@$PlvZk|W;bTv10BssRp>+^3uxxz}(JTeff=P1X4@qt9bpNQOx&vtuD$|KK}O7L|r zPCnq}52rRl1c&!S!v}Y2IVYv*7PLn$_D~RKS0T*nzn-91D(E!1byv&f?Z{ruYU74P zrjSvw{dwwL^y`3CWK=ZTYTQL~x1$?;YbHFSZP+X_RkZUG)g=zCch{OE{|Xb&2vP1{&Y>bl#uq&qEYvZ~d4y$f6r^gOg5Jtp{DZn$S*UBAnd!w`w{jCg0D! zp^d{zIniI;dJc{#L<)@pp<9^Bo>`8jBtO)Q>oU3#-0C1WyWaQULs}$dm1ce0c$oNh z2=0^AVQb!I+~e8p`Ede>wHlXz=~`2g8wKSgtp7<7-P6Y1_n3M}BUTVt1%Lm>_xpqr zvH3j5WrCOQ5n?0%wFcj9*UGE2U%&5RENssNI0!zwWNCirNV;!OZbNI5E_BsD|5y_! z7B&Bl;t_H|ULXbs_}lh8TQnL=eg(Vae%(#@ZhFen)_c_c7@Px#+DUQo^v$F2_~`BB z@t6OOkE;Tw5ficC4*j&*aF_{`1grQoV-8RNVC}zDZCua8O95QYN`q%9FBPuayqV@)@?73BB;k7fMUBhZRR*Z8Gr>Z_0Swe4zh^g?0&VsVa3JQ zAV$y;WH?_T7nKYO$XjPSu1UI9W-!{5#gst$p=0+Yy`f?5qTBi7;pM6p=Nu|yDvVS` z@GuTJOb)8>=)_HVWXfab!Yd_^9md?>`9so}DNi;!>Q{j8+kNZnIu~W~DX^3sE4|z2 zmSM)K{$+GRFOq857!pM}ifk;EyAORf&1n@D=*hbzXtXNKB5&2h#9*>>*+L2Z;(oa} zZnO4hJx(boDLnUs;scZBIt4`|Q|!m9;b}!oOl)u^JKurs>e)y6$3^p2+Pwv>*VTrh zu`%q|dY2EvPo{s=yyv*10tbDkx1mma-}&BLk<;7jQ`wYvtu^6zcVhy^5vadpXQEJ& z>y;mTd?DfgAx4HRwuh}5mnn8~u(bD4?TS^!*6I7ecZVNp@OZwV8SVD0w$n-(5Go|5 zJ-GD>X^}KOxT*>JBR?L#ioUaBp#edBFy~Lhv=DetAO{bD0}sv8vT~#lQ*+Pi+{x~n zC}K4yh9RoOB6v9e&>#zmt5~W&V#KDBdOKVXkYba&>ne^V7yWVd&NYwqjLkB2tWBMJ zhDAaRsRDrXy*EG)WPx>GmL$Rb4S$Re7S^x=!jiYqC3$l<(jY$YVH3*d^1`TK&olb>%Zt<4Cn>9vew2oCY;_flCi%1yr7nD_nmm=cMy#B;}$W$lti1@qlVGRhCtf;K4^jZSEC!= zRHu!*ooAcioWm}dW}(-gb=w6Y-Py_at!~g^TI_OwB3V0pm=l{8e)FkViHXTnnPq1O z*>Er`*)VG0wXRkV5?6#RB=YVB!k{An;D3hw+GqlV+rEtHS4Zc7ZhC?O{5(flt2sDA z|Ehkj`Z{+16fDB^l$_d&yqTQ*<~;in4;Ff#*a@fbjrfmfzch3c+*GyGq2gw<+1OtQ ztX%7^#TCTx9xcxZr?hWiI+UQdh!^oV2K9#V8{y!E?yLplemuFjrE(a*zvR$4*P`!x zLHdf^&O_7Uw@aSvjAw+jZS5@vV~ksUFFHbnYc#UVk8@|V6R50n)$+4@*4cDDlyELA zJTF#r?tV(nuMte}u2&f@5t?|%`Z{9asi)my5~ezb-p@uw!POm00Q_6BXFWnho3A{8 z+t9W65j-twCEvS-pQ2(tU?%)3y3k9`>e2T}yqF~>eg0NChbGsyVY5i^5wicq>S#n)wf!f_+T58R2rRb})g)He0u7%jz%w!k2AT>QXQNJ45^`e-F|L~L-6xjVANzap_qC%#q z*ZV}MAXUSQ-la4Nvv~Q8)NGP-f2Ro+PCQo2JA_k-FS=ZR3QFfKCP@!Op8sZ}V12b2 zH(OTR1Hb;H%z%IOa@jt$ups}SPbKf?d2Mr#8&b;OBWb9`EaFY50uK0iarU0(n&s+Y zM<9hKxa{uUgE$13huq!!P$0Dfd_Ez;Am?x{EI%b=Q|Me)jVzF=Q6K(Kf6@ItgQkSvlYFV;h92+^2 zd%#dE)i`>(g)rw8(mpEx78!08g#Oy=$6TB}VAZeI$RVEkX!^vc=(Sl$7B7~~eerjg zF#F@A4<4MC(7&;?)1@gf0#_Cfga`hCa4RdOL0QlmV%%s<97i5HJsU~BnWNAlu(NCV z$*$>C@#~6Mz_KzqQ(w-FJX;f6B%b!x)@+LxgC~q*%oSS$SGONgem*=)3uO67s#}IP zZ+3&J@D><}FdpJ$cyP;WFWkeZJrdN=9oBmGjEY0tQ;Najh$m1S*OXvOC|mb;ZS;?? z(QzyaSpQhejz?0&R_D%Ixh_y~$jHu3vv|t3z2CdUzx2c86tZSzWl^#DJhunang+o8 zh;lfKT_3byc+~+qwEaT-(&H=`hY&?P6P@SUmkRQ^*?EgKFHR1aSJBR)B3y6_goMTK zz^n$fYyZs!?D5>{-Cu67o^%eV+w7}pa&ijlU-Ri$3@JQ$8(#d?{bZU&#>W<4>y^0) z7%mH!Bvj~z&mls~MWi_1KR7`Sc%=*`el)l;q57!Xv%uNP$ay)C7#4p#-l_Zk_Vr!Y z7F1E9fR<`9*3tq~M6~OZ>H&S|=m*unURbO(F)(}y zWer|tF1*YyplwY>`M-F^+ySHaoTB`)v=ja5TIcoJK`qWdnKf<@hlH6S;y%cysE8jE z+vK!Fu(%H4TSk*v1KvDeQcYEkI)o`@QsW-ZpPtXVF-gFJC&2CSGS<&Ck9!Z-*FYY# zW7@botcljVOiG~8SPoU+WHtJ}hcr&%=KcnBJuH?a;#L+b)4NI=`Z7U*Vp!lDbLZ*V zQjILl1GpLfPGF-=&$Pnq9ajVgIqEGW=1;L&_V5>qX6FrGn`=MV=SNgjKng3?@MnL& zj@(#sE4aFNNU+5a`WkUBdHVLl~h`(Znre*eXm zjz{EWPD9Yv|BhcP{(t7J&yl`K2-=_3KODj#qWk3jrft5Nf0WE*ZNnm$P-(nQ zu2{&qc)w|#(fm%@pFy5;0T8hrrrp@s+0&FveZRU}0nKQZ*j%1K^x}$6(>^953HxNW zu|RbJTSMS>k$68k$NdQRqk!k#ZS0vKNDd&H_#SVg1YZA6c~Rnn(SPUYjt@L^5LhE3 zxedOnrM{!h6c%52z<5{(HU-=~xun(*iA>98Q4+oMHLO3Z*Vy_1{Z8)=SQln_8qw@V z+88 zkTB)`^eMuFRLH&5o$B{GN$F1jgy!q2X8X;Jcn=Ysxe0Q1db!X@uT3OT^7RYn8 zU;5nI*f$*c{RpZVVLW)Iw|_gXRjoTr50-nBuP83FHpzWi z?B<-I99koxoilNbQbL}5ZslhE#NQ((f=M205w&&0XsR+%b}Dfb5YZT?1jV25-tKst z)o6dr!-c!^J~Asn5G^oG2Jd=1T6A*wAX~(4I%F;JxA~<$h}*W5B&BRedGG5h7N0)q z2{5eu!}=fWVR`C#vui}f0zVatD-5V-JWWg*kGlZXh*xfdABoW2iL9G=xB0UUW|{=! zU1F>3aM&Gt3&D)@NmW8q_N$>n6>%H1DW2UJe86;1uN97Qg9fG2>Y(xftP0L5p{sh~ z(clvnUkCJn)iCU5jL=;3oH6|XrGaQzxNJVO7Fx3`sr|uW0h)B|72J9J6oLe$sxqRv za*ay3`EiZ%7f!u*1buTkKe^L^EJ@?|Y++r}+Q<`?uf&%QTNI}PkL)&Py?=Wq4iIyS zdi{R|5kOs{0(9^YGQk9xol>;ftV?i_57tbX?x11rE-LJYliWDWzdK}PWD;sGp)T~( z3W)(2S|0wK>87b>ynXK6JL`qBw$;Q#8*IMi0h=w-kAn+G^*5$FuflVm?hIGZHl((? zv?LVJe7|S~vP*jg482+~3ay1h1iRL+L+IpebKJbJomm>YweWKhdNib>a-X&Z+ON7W z7+hE!xg_k-xG8PJdUCt;Of{FGgv4BQPfi*T%TjYxRH*e)rf@#=@9tW(gg>IT<0`f^ z!QvPnKvVFZAp8fdj8hno@T)rXBU~^@wtb{Xn_b|ya4ucD=l9@^CWocZmg!MOl-_Q~ zia>+3Es;+-P}T4xMwg+&e?m;y)wMjg2qDZ$drNs0@0=`6}A0viuG}BjE+^kJXFa$Sb4%R1k z$E#Fz`ni86*Aqeg*>=i)h$gvAtV-C1Jt zMT$Y!d6=|duEw_@epjR}VVSFfyqvMYW~7%|T!B=Z&q9b0W;mA}I6nvLO!^N>eldry z(|i`Tc#vF|*}%IkN05m-f{zSFrdO+oY5@@)y*U8<5EAG0aC=EV>)GZg{SSE+2~1cB z|6U}lA)%FS0bWjCB$EqSXH~-=A9?iDxIX6yG<~6s;tty__g2_**I~w2v^E z*9gPF*r;QM?lqU5@XcyIJ ziQ1OiuBzbW+IzXGa#x?pm9{F6=?DEy;0X*MS11bsI`Ai9Hu|B=y3QFQ3%)5rOE?|@ z_(z&mV5zK3D5YlN523_EBQZZQwPio_6w22z0DSrQfJMYE8pdmsyFu?=>({sN$~AO! z)It}L_=kLQ5*)7It}?7`_P&$4eaVs%p!5Hvttb7Lrx|m5v5gt3US~JWYd2R;q*u)F z#pI^bdeCVOA-dzI#-uxw|D`_cn#@3^!$m3m+oGn;=Ui}0Fh*bR>~LqDf;J*HZgjG%YYIdk5` z8OX5Qsf5$m*g5Ud3A5h4eQOQ7u~e!48Gt-b8x)6u@nB{E67Bll#3IJ7by(X2e{8$Q zy6R4Dv|Qz@xbXH5uU?%E?8dra zP8bD$4qJYiKd6y(Q^H8QRx~`F-V;M`B5nZn=s7Jg2coa?t3}rpbIp^A>m~92+Ki(! zW^{{~O(qVVFm6>7ignMmZP;dnOFhvl~@Sg1NIV~zJWsH3aX9pWX zUTU3F=sBXemWl5XBuSqWf}03SQ%o^BSn&TPh-s9v);@pd^4_*U>^#?eG#}0 z8_ODag9&89rvp|FYHi~Ei{yt5HitfeiTqj1(h?_^2+MGei+YNB03{ld>1L#0}tuQ2q(u!U*TA*e4@fFyIzA;2V{L(?VS z9sYQaC1$JdV6Lx(GDgf|8mJu-={G42-k~mOj$0(2Caq3FqrZ6fdS*M9o9`rB?3uE& zqB^9lHv>!MUBfaqI@+=Jb1Ku$SpTHpGIvMMUq)z&h9rl!OJN)WyXo;nLpjW$Z)jf$ z(tju=ZQxX6(=_sxK*7UrHYLPfGPw7w>$x*TFgf%Xw^yhxLGtwWl(Gymmdq-C+6Lq- zIP;d!v~g$gQ@snWzq3lV5hB;6qbjjv!16eA)&?@q`_jawe+G54tb$JgC2uPMmIVN`*#F}&@@PVTl3Ql zIGBN)zZDw8Ur_w5Y>`R}lv&BDhH_=+gHtx`Kp@+OZ*l_T@G{ErL4FzhZo+lu(YuVV zRLW?ZVdpir))5cr#Y&x0?~1_saDf2V1zrZtTt-YQ7r ziD$s5)zs(|?6hrHcx^+{sEdIoagjowzn@xPzQxh+uAxZOPvZMhD8YK9*~26GDqlkq zh1!95Qgb{_>%1S*Dn=CB8TwZGG=KdwPp1QeLjbWbtovf2xU2G-RnA+rE1Nq>=(Biq^s8L!9`L@bYQjqzCEcu zarA#;ipBy&zQ1<`)R+d!VF*oVB+qa{8B}hyxFjnDe<>-fYYU2Gt*Yy3%?WI@nWKHs}o<}+4hzG!+sn-;H1gxJ{uP2F22*^ z@ehw2uh8;{?*u9p>Jd+a0?iy)BWU}D%1GKX*muqK3N0NQbl3N=4U+!FtV1Yd)3Rr@ z^%c9ZrrqN>fk{*f5B4+RGBo2cmIvWDXu zzSW=j#1UQN>`cX&!uz-I0-st3Bwu+-M&XLs{~R81K(9J*AZVl!EJj`w zP<;DdH_Z#Zhl3`qMSdxx6cY3mkwZeVTBF^`P1>a`QNrOBNmz1l&J{B)2bKXP(C=;b z*HphO^jPB-E~1mFUCOGEYkFn}@l7DUgoNfZW}%gQgX1j4>Oa?(;K3=|N0=^kC_h*Su{hx~MI+p7p&WO9BKAph=~ZGxm?SC zx^xCL=xDmgfskaq0G2Kn8`0{gfQ$ePrlyPJm5FPCal0orV1Uw+=BL7})J3UpFYL#4 ztZ{8!Y27v{-{8s2MvMW95nF0jkrwICCOHjE&^KM$8X4qWJNt{NKT-dAdWEV-a=Oo>hA zj1w7wSq_IwSNt@MDH((7Z5KNxTTL}R7j2`gvu@^%M3-S1wM}osUS#Bp7zon&EQFDU zsq0vZ?~3OfkK;7m!W?nj4qdwckV5G9S|FZ{SxUNthaAw3ym!S~& z&|UJE@&^XHXx3O*+NjZ2gr#dt4r$#uNcStSG5yCFPorV!qHcPjw3uwTgIFPEnxnFt z2s)5OT=QK_w2>48{cdJ3He;5?0ZkzHcNEscL$AKarb=z^4rbTS98ihj4(41wy==qY zI!5aZ)pTv2F;*G2mtt;A0bqAw7Eubn3NpP5O|H#jf-xCB+j~$_dSe) z5+%R@Req6bZ%eLPjpDc5pVo;&n?CvEq6d_KO?qd{S#N}ZI|0Gt%&n5RKSI&Z1xCel zyXm#74I4Zy*TsVcFc&_jH1~n+LgL;x?Y`xsW{aU>_`^@Z^%Pt?=%6CTDNnfro4F!P z9`^JSANNvj+1BN~zCjRMg!SBBRIs~~-3<{NY#WqmuS)fYFk24^l0Z*G?(21)6kG&C z#dh0vY`@*KsJyp3<;bePS!S;>f?x1jtH)O String(s || '').replace(/[\s*()()【】\[\],,。.、::·\-—]/g, '').toLowerCase(); const body = norm(src); + // 「删掉 / 取消 XX」这类否定语境:模型按要求在「风险与备选」里说明取舍时 + // 会写到,这些地点不该被画进路线图(nearby 沿用与继承两条路径都要判)。 + const NEG = /(删掉|删除|取消|去掉|移除|不再|不安排|舍弃|放弃|避开|跳过)/; + const negated = (k) => { + const i = body.indexOf(k); + return i >= 0 && NEG.test(body.slice(Math.max(0, i - 12), i)); + }; const pool = []; const push = (name, extra = {}) => { @@ -909,14 +918,31 @@ function extractPlanPlaces(text) { if (!n || n.length < 2) return; const key = norm(n); if (!key) return; - if (pool.some((p) => norm(p.name) === key)) return; + const old = pool.find((p) => norm(p.name) === key); + if (old) { + // 同名时保留「带真实坐标」的版本:正文加粗词只有名字, + // nearby / 继承点才有坐标。让后者补上去,否则 resolveRoutePoints + // 会因为缺坐标把这些点全部丢掉。 + if (old.lat == null && extra.lat != null) { + old.lat = extra.lat; + old.lon = extra.lon; + if (extra.city) old.city = extra.city; + if (extra.district) old.district = extra.district; + if (extra.kind && old.kind === '地点') old.kind = extra.kind; + if (extra.from) old.from = extra.from; + old.resolved = Boolean(extra.resolved); + } + return; + } pool.push({ name: n, ...extra }); }; // 1) nearby 点名地点(餐饮/景点):模型被强制点名的,名字必定出现在正文里 const nb = state.nearby || {}; for (const x of [...(nb.sights || []), ...(nb.food || [])]) { - if (x && x.name) push(x.name, { kind: x.type || '地点', lat: x.lat, lon: x.lon, from: 'nearby' }); + if (!x || !x.name) continue; + if (negated(norm(x.name))) continue; + push(x.name, { kind: x.type || '地点', lat: x.lat, lon: x.lon, from: 'nearby' }); } // 2) 已选目的地 const a = state.analysis; @@ -936,6 +962,24 @@ function extractPlanPlaces(text) { push(n, { kind: '地点', from: 'bold' }); } + // 4) 沿用上一版已定位的地点坐标。 + // 追加轮里模型常把「**XX景区**」改写成「XX」或直接不重复加粗, + // 只按本版加粗词提取就会丢坐标。这里只继承「本版正文里仍出现」的点, + // 用户明确删掉的地点不会被画回来;名字用 4 字前缀兜底简称。 + for (const p of (Array.isArray(inherit) ? inherit : [])) { + if (!p || p.lat == null || p.lon == null) continue; + const k = norm(p.name); + if (!k) continue; + const fullIdx = body.indexOf(k); + const appears = fullIdx >= 0 || (k.length >= 4 && body.includes(k.slice(0, 4))); + if (!appears) continue; + if (negated(k)) continue; + push(p.name, { + kind: p.kind || '地点', lat: p.lat, lon: p.lon, + from: 'inherit', resolved: p.resolved, + }); + } + // 按在正文中首次出现的位置排序 → 行程顺序 const withPos = pool.map((p) => { const k = norm(p.name); @@ -998,6 +1042,26 @@ async function resolveRoutePoints(places) { } } +/** + * 按某版方案正文重算路线点,并把结果写回该版本对象。 + * 生成新方案、载入本地保存的方案都走这里,避免各处重复解析逻辑。 + */ +async function refreshRouteFromText(text, inherit, ver) { + try { + const places = extractPlanPlaces(text, inherit); + const r = await resolveRoutePoints(places); + state.routePoints = r.points.filter((p) => p.lat != null && p.lon != null); + state.routeMissing = r.missing || []; + } catch { + state.routePoints = null; + state.routeMissing = []; + } + if (ver) { + ver.routePoints = state.routePoints; + ver.routeMissing = state.routeMissing; + } +} + /** 静态地图最多能承载的路线点数(服务端 staticMapMeta 同样按 9 点截取) */ const MAX_ROUTE_POINTS = 9; @@ -1042,7 +1106,9 @@ async function drawRouteMap() { // 优先用「方案里实际安排的地点」画线路;方案还没生成时才退回已选候选 let picked = []; let label = ''; - let missing = []; + // 没能定位的地点要在底图提示里如实说明;原来这里是空的局部数组, + // state.routeMissing 从没被用上,导致「有 N 个地点未能定位」永远不显示。 + let missing = state.routeMissing || []; let total = 0; if (state.routePoints && state.routePoints.length) { total = state.routePoints.length; @@ -1324,8 +1390,9 @@ async function runPlan(opts = {}) { .map((c) => ({ ...c, direction: compassCN(c.bearingDeg) })); if (!shortlist.length) return toast('请至少选择一个目的地', 'err'); const isFollowUp = Boolean(opts.followUp); - // 追加轮把「当前这一版」交给模型当底稿;首次生成则是空的 - const previous = isFollowUp ? state.planText : ''; + // 追加轮把「用户当前正在看的这一版」交给模型当底稿;首次生成则是空的。 + // 不能直接用 state.planText:查看历史版时它仍是最后一版,会基于错误底稿调整。 + const previous = isFollowUp ? currentPlanText() : ''; // 先补「可点名的地点」:只给住宿 POI 的话,模型只能写"某某片区 + 自行核实"。 // 这里对主目的地查一次周边餐饮 / 景点,塞进 nearby 一起交给模型。 @@ -1348,6 +1415,12 @@ async function runPlan(opts = {}) { } } catch { /* 取不到就照常生成,不让它阻断流程 */ } } + } else if (state.nearby && shortlist.some((c) => c.name === state.nearby.area)) { + // 追加轮沿用上一轮的 nearby:已选目的地没变时,这些坐标仍然有效。 + // 若在这里清空,模型上下文里就没有「可点名的地点」,第二版会退化成 + // 「某片区 + 自行核实」,extractPlanPlaces 的来源 1 也跟着空 → + // 路线图只剩已选目的地(常常只有 1 个)→ 用户看到「只剩一个点」。 + nearby = state.nearby; } // 存一份供前端提取方案里的点名地点(路线图要用它们的坐标) state.nearby = nearby; @@ -1426,6 +1499,9 @@ async function runPlan(opts = {}) { needs: opts.followUp || '', text: state.planText, at: Date.now(), + // 路线点属于「这一版方案」:切版本时要用它重绘,不能停在最后一版的点 + routePoints: null, + routeMissing: [], }); renderVersionBar(); } @@ -1439,14 +1515,11 @@ async function runPlan(opts = {}) { $('#planExtra').classList.remove('hidden'); // 先把方案里点名的地点解析成真实坐标,再画线路。 // 这一步要等方案正文定稿,所以放在生成之后而不是之前。 - try { - const places = extractPlanPlaces(state.planText); - const r = await resolveRoutePoints(places); - state.routePoints = r.points.filter((p) => p.lat != null && p.lon != null); - state.routeMissing = r.missing || []; - } catch { - state.routePoints = null; - } + // 追加轮把「上一版已定位的点」作为继承池传进去:模型改写后不再重复 + // 加粗的地点,只要正文里还出现,就沿用真实坐标,路线图不会塌成孤点。 + const curVer = state.planVersions[state.planVersions.length - 1]; + const prevVer = isFollowUp ? state.planVersions[state.planVersions.length - 2] : null; + await refreshRouteFromText(state.planText, prevVer && prevVer.routePoints, curVer); // drawRouteMap 是 async:必须 catch,否则它内部任何异常都会变成 // "未处理的 Promise 拒绝",路线图会无声消失(之前踩过:变量未声明 → ReferenceError) drawRouteMap().catch((err) => { @@ -1514,7 +1587,7 @@ function renderVersionBar() { }).join('') + ''; } sel.innerHTML = html; - if (list.length) sel.value = `v${list.length - 1}`; + if (list.length) sel.value = `v${state.viewing != null ? state.viewing : list.length - 1}`; sel.classList.remove('hidden'); $('#comparePlan').classList.toggle('hidden', list.length < 2); $('#savePlan').classList.toggle('hidden', !list.length); @@ -1817,8 +1890,19 @@ function init() { $('#resetPlan').addEventListener('click', () => { if (!state.basePlan) return toast('还没有可回退的初版', 'err'); state.planText = state.basePlan; + // 指回第 1 版:paintPlan / currentPlanText / 版本下拉都以 viewing 为准, + // 若留着历史版索引,「回到初版」正文与下拉都不会跟着变。 + state.viewing = state.planVersions.length ? 0 : null; state.planRound = 1; paintPlan(); + // 路线图也跟着回到初版 + const first = state.planVersions[0]; + if (first) { + state.routePoints = first.routePoints || null; + state.routeMissing = first.routeMissing || []; + drawRouteMap().catch(() => {}); + } + renderVersionBar(); $('#outputMeta').textContent = '已回到初版'; $('#followupCount').textContent = '会基于当前方案重写完整版本'; toast('已回到初版方案', 'ok'); @@ -1838,6 +1922,11 @@ function init() { toggleCompare(false); paintPlan(); const ver = state.planVersions[i]; + // 路线图是方案的一部分:切版本时同步换成该版的线路点, + // 否则正文已经是第 1 版、图上却还是最后一版的点。 + state.routePoints = ver.routePoints || null; + state.routeMissing = ver.routeMissing || []; + drawRouteMap().catch(() => {}); $('#outputMeta').textContent = `第 ${ver.n} 版${ver.needs ? ' · ' + ver.needs.slice(0, 16) : ''}`; return; } @@ -1847,7 +1936,9 @@ function init() { state.viewing = null; state.planText = s.text; state.basePlan = s.text; - state.planVersions = [{ n: 1, needs: s.needs || '', text: s.text, at: s.at || Date.now() }]; + // 本地保存的方案没有 nearby 坐标,路线点需要按正文重新解析一次 + state.nearby = null; + state.planVersions = [{ n: 1, needs: s.needs || '', text: s.text, at: s.at || Date.now(), routePoints: null, routeMissing: [] }]; state.planRound = 1; toggleCompare(false); $('#planOutput').classList.remove('empty'); @@ -1855,6 +1946,8 @@ function init() { $('#planFollowup').classList.remove('hidden'); $('#outputMeta').textContent = '已载入保存的方案'; renderVersionBar(); + refreshRouteFromText(s.text, null, state.planVersions[0]) + .then(() => drawRouteMap().catch(() => {})); toast('已载入保存的方案,可以继续调整', 'ok'); } }); diff --git a/tools/test-route-map-followup.cjs b/tools/test-route-map-followup.cjs new file mode 100644 index 0000000..f0d9045 --- /dev/null +++ b/tools/test-route-map-followup.cjs @@ -0,0 +1,162 @@ +'use strict'; +/** + * 回归测试:AI 追加轮之后「行程路线示意」不能塌成一个点。 + * + * 复现路径(修复前): + * runPlan 追加轮把 state.nearby 置 null → payload.nearby 也变成 null, + * 模型第二版不再点名具体地点;extractPlanPlaces 的 nearby 来源为空, + * 加粗词又常被改写成简称 / 不再加粗,无坐标的点全被 resolveRoutePoints 丢掉 → + * 只剩「已选目的地」(常常只有 1 个)→ 图上孤零零一个点。 + * + * 本脚本不启动浏览器:把 app.js 里「地点提取」相关的纯逻辑切出来放进 vm, + * 注入 mock state,直接比较初版 / 追加轮两版的路线点数,并断言源码里的修复点。 + * + * 用法:node tools/test-route-map-followup.cjs + */ +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const APP = path.join(__dirname, '..', 'public', 'app.js'); +const src = fs.readFileSync(APP, 'utf8'); + +const start = src.indexOf('const PLACE_SUFFIX'); +const end = src.indexOf('async function resolveRoutePoints'); +if (start < 0 || end < 0 || end <= start) { + console.error('app.js 结构变了,切不出 extractPlanPlaces'); + process.exit(1); +} +// 这一段只含常量 + 纯函数,不碰 DOM,可以直接在 vm 里跑 +const snippet = src.slice(start, end); + +// ---------------- 造数据:柳州市周边 10 个可点名地点 + 1 个已选目的地 +const LAT = 24.32; +const LON = 109.42; +const pt = (name, i) => ({ + name, + lat: +(LAT + (i % 5) * 0.004).toFixed(4), + lon: +(LON + Math.floor(i / 5) * 0.004).toFixed(4), + type: '地点', +}); +const SIGHT_NAMES = ['龙潭公园', '马鞍山公园', '窑埠古镇', '柳侯公园', '程阳八寨景区']; +const FOOD_NAMES = ['螺蛳粉老店', '青云菜市', '五星步行街', '水上公交码头', '龙潭公园餐厅']; +const NEARBY = { + area: '柳州市', + radiusKm: 8, + sights: SIGHT_NAMES.map((n, i) => pt(n, i)), + food: FOOD_NAMES.map((n, i) => pt(n, i + 5)), +}; +const CANDIDATES = [{ id: 'd1', name: '柳州市', lat: LAT, lon: LON, score: 9.1 }]; +const IDS = ['d1']; + +// 初版:模型按提示词加粗点名每一个具体地点 +const V1 = `# 柳州两日行程 + +## 方案总览 +两天都在柳州市区与近郊,节奏适中。 + +## 逐日安排 +### 第 1 天 +上午去 **龙潭公园**、**马鞍山公园**,中午在 **螺蛳粉老店** 吃饭; +下午逛 **窑埠古镇**、**柳侯公园**,傍晚到 **五星步行街** 与 **青云菜市**。 +### 第 2 天 +去 **程阳八寨景区**、**水上公交码头**,晚上在 **龙潭公园餐厅** 收尾。 +`; + +// 追加轮:模型重写完整方案,不再重复加粗点名,并在取舍里明确删掉两个点 +const V2 = `# 柳州两日行程(第 2 版:少走路) + +## 方案总览 +按「少走路」重排:第 1 天压缩成市区一圈,第 2 天只留一个远点。 + +## 逐日安排 +### 第 1 天 +上午去龙潭公园、马鞍山公园,中午在螺蛳粉老店吃饭; +下午逛窑埠古镇与柳侯公园,傍晚到五星步行街。 +### 第 2 天 +只保留程阳八寨景区,删掉水上公交码头与青云菜市(走路太多)。 +`; + +// ---------------- 在 vm 里跑真实前端的提取函数 +function runExtract(nearby, text, inherit) { + const ctx = vm.createContext({ console }); + ctx.__nearby = nearby; + ctx.__candidates = CANDIDATES; + ctx.__ids = IDS; + ctx.__text = text; + ctx.__inherit = inherit || null; + vm.runInContext(` + var state = { + nearby: __nearby, + analysis: { candidates: __candidates }, + selected: new Set(__ids) + }; + ${snippet} + var __result = extractPlanPlaces(__text, __inherit); + `, ctx); + return ctx.__result; +} + +let total = 0; +const fails = []; +function check(name, ok, detail) { + total += 1; + console.log(`${ok ? ' OK ' : ' FAIL'} ${name}${detail !== undefined ? ` -> ${detail}` : ''}`); + if (!ok) fails.push(name); +} + +console.log('== AI 追加轮路线点回归 =='); + +// 1) 初版:nearby 10 + 已选 1 = 11 个点,全部带真实坐标 +const v1Points = runExtract(NEARBY, V1, null); +check('初版提取到多个地点', v1Points.length >= 10, `${v1Points.length} 个`); +check('初版地点全部带坐标', v1Points.every((p) => p.lat != null && p.lon != null), + `${v1Points.filter((p) => p.lat == null).length} 个缺坐标`); + +// 2) 复现修复前 bug:追加轮 nearby 被清空、无继承 -> 只剩 1 个已选目的地 +const broken = runExtract(null, V2, null); +check('修复前:追加轮塌成 1 个点', broken.length === 1, `${broken.length} 个`); + +// 3) 修复后:nearby 沿用 + 继承上一版坐标 -> 仍是多点 +// 其中两个点被「删掉」明确排除,所以是 9 个而不是 11 个 +const fixed = runExtract(NEARBY, V2, v1Points); +check('修复后:追加轮仍是多个地点', fixed.length >= 8, `${fixed.length} 个`); +check('修复后:追加轮地点带坐标', fixed.filter((p) => p.lat != null).length >= 8, + `${fixed.filter((p) => p.lat != null).length} 个带坐标`); + +// 4) 继承路径单独生效(即使 nearby 也没拿到,坐标不会全丢) +const inheritOnly = runExtract(null, V2, v1Points); +check('继承路径独立生效', inheritOnly.length >= 5, `${inheritOnly.length} 个`); +check('继承点带真实坐标', inheritOnly.every((p) => p.lat != null && p.lon != null), + `${inheritOnly.filter((p) => p.lat == null).length} 个缺坐标`); + +// 5) 明确删掉的地点不被画回图上(nearby 沿用 + 继承两条路径都要排除) +const deletedFix = fixed.filter((p) => /水上公交码头|青云菜市/.test(p.name)); +const deletedInherit = inheritOnly.filter((p) => /水上公交码头|青云菜市/.test(p.name)); +check('「删掉」的地点未被画回图上', deletedFix.length === 0 && deletedInherit.length === 0, + `nearby 路径 ${deletedFix.length} 个 / 继承路径 ${deletedInherit.length} 个`); + +// 6) push 覆盖:正文加粗词只有名字,继承点带坐标 -> 同名点应补上坐标 +const V3 = '### 第 1 天\n只去 **龙潭公园** 与 **窑埠古镇**,其余取消。\n'; +const covered = runExtract(null, V3, v1Points); +const lp = covered.find((p) => p.name === '龙潭公园'); +check('同名加粗词被继承点补上坐标', !!lp && lp.lat != null && lp.lon != null, + lp ? `lat=${lp.lat}` : '未找到'); + +// ---------------- 源码级断言:确认修复确实落在 app.js / planner.js +console.log('== 源码修复点 =='); +check('追加轮沿用 state.nearby', /else if \(state\.nearby && shortlist\.some/.test(src)); +check('追加轮底稿用 currentPlanText()', /isFollowUp \? currentPlanText\(\) : ''/.test(src)); +check('版本对象存 routePoints', /routePoints: null/.test(src)); +check('版本切换同步重绘', /state\.routePoints = ver\.routePoints/.test(src)); +check('回到初版同步重绘', /state\.routePoints = first\.routePoints/.test(src)); +check('提取支持 inherit', /function extractPlanPlaces\(text, inherit\)/.test(src)); +check('否定语境护栏共用', /const negated = \(k\)/.test(src)); +const planner = fs.readFileSync(path.join(__dirname, '..', 'lib', 'planner.js'), 'utf8'); +check('追加轮提示词要求保留点名地点', /不要退化成「某片区」/.test(planner)); + +console.log(`\n通过 ${total - fails.length} / 失败 ${fails.length}`); +if (fails.length) { + console.error('失败项:' + fails.join('、')); + process.exit(1); +} diff --git a/tools/verify-route-map-followup.cjs b/tools/verify-route-map-followup.cjs new file mode 100644 index 0000000..3f3ce8f --- /dev/null +++ b/tools/verify-route-map-followup.cjs @@ -0,0 +1,245 @@ +'use strict'; +/** + * 端到端验收:AI 追加轮之后「行程路线示意」不能只剩一个点。 + * + * 用真实无头 Chrome 打开站点,注入一份 mock 分析结果与两版方案正文, + * 直接调用页面里的 refreshRouteFromText / drawRouteMap,读真实 DOM: + * - 初版:多点点 + * - 复现修复前(nearby 清空、不继承):只剩 1 个 + * - 修复后(nearby 沿用 + 继承上一版坐标):恢复多点 + * - 切换回第 1 版:路线图跟着版本重绘 + * + * 用法:node tools/verify-route-map-followup.cjs + */ +const { spawn } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const OUT = path.join(__dirname, '..', 'preview'); +const CDP_PORT = 9361; +const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe'; + +function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } + +class CDP { + constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); } + static async connect(url) { + const ws = new WebSocket(url); + await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); + const c = new CDP(ws); + ws.onmessage = (e) => { + const m = JSON.parse(e.data); + if (m.id && c.waits.has(m.id)) { c.waits.get(m.id)(m); c.waits.delete(m.id); } + }; + return c; + } + send(method, params = {}, sessionId) { + const id = ++this.id; + return new Promise((res) => { + this.waits.set(id, res); + this.ws.send(JSON.stringify({ id, method, params, sessionId })); + }); + } +} + +const LAT = 24.32; +const LON = 109.42; +const pt = (name, i) => ({ + name, + lat: +(LAT + (i % 5) * 0.004).toFixed(4), + lon: +(LON + Math.floor(i / 5) * 0.004).toFixed(4), + type: '地点', +}); +const SIGHT_NAMES = ['龙潭公园', '马鞍山公园', '窑埠古镇', '柳侯公园', '程阳八寨景区']; +const FOOD_NAMES = ['螺蛳粉老店', '青云菜市', '五星步行街', '水上公交码头', '龙潭公园餐厅']; +const NEARBY = { + area: '柳州市', + radiusKm: 8, + sights: SIGHT_NAMES.map((n, i) => pt(n, i)), + food: FOOD_NAMES.map((n, i) => pt(n, i + 5)), +}; +const ANALYSIS = { + origin: { name: '河池市金城江区', lat: 24.69562, lon: 108.08091 }, + candidates: [{ id: 'd1', name: '柳州市', lat: LAT, lon: LON, score: 9.1 }], + timeRange: { start: '2026-11-20', end: '2026-11-21', days: 2 }, + dataNotes: [], +}; + +const V1 = `# 柳州两日行程 + +## 方案总览 +两天都在柳州市区与近郊,节奏适中。 + +## 逐日安排 +### 第 1 天 +上午去 **龙潭公园**、**马鞍山公园**,中午在 **螺蛳粉老店** 吃饭; +下午逛 **窑埠古镇**、**柳侯公园**,傍晚到 **五星步行街** 与 **青云菜市**。 +### 第 2 天 +去 **程阳八寨景区**、**水上公交码头**,晚上在 **龙潭公园餐厅** 收尾。 +`; + +const V2 = `# 柳州两日行程(第 2 版:少走路) + +## 方案总览 +按「少走路」重排:第 1 天压缩成市区一圈,第 2 天只留一个远点。 + +## 逐日安排 +### 第 1 天 +上午去龙潭公园、马鞍山公园,中午在螺蛳粉老店吃饭; +下午逛窑埠古镇与柳侯公园,傍晚到五星步行街。 +### 第 2 天 +只保留程阳八寨景区,删掉水上公交码头与青云菜市(走路太多)。 +`; + +let server = null; +let chrome = null; + +(async () => { + if (!fs.existsSync(CHROME)) { console.error('找不到 Chrome:' + CHROME); process.exit(1); } + fs.mkdirSync(OUT, { recursive: true }); + + // ---- 服务端(端口可能被占用自动顺延,从启动日志里读真实端口) + server = spawn(process.execPath, ['server.js'], { + cwd: path.join(__dirname, '..'), + stdio: ['ignore', 'pipe', 'pipe'], + }); + let banner = ''; + server.stdout.on('data', (d) => { banner += d.toString(); }); + server.stderr.on('data', (d) => { banner += d.toString(); }); + await sleep(2800); + const m = banner.match(/http:\/\/127\.0\.0\.1:(\d+)/); + if (!m) { console.error('服务端未打印监听地址:' + banner); process.exit(1); } + const base = `http://127.0.0.1:${m[1]}`; + console.log('服务端 =', base); + + // ---- 无头 Chrome + CDP + chrome = spawn(CHROME, [ + '--headless=new', '--disable-gpu', '--hide-scrollbars', + `--remote-debugging-port=${CDP_PORT}`, + '--user-data-dir=' + path.join(os.tmpdir(), 'tripverifyroute' + Date.now()), + 'about:blank', + ], { stdio: 'ignore' }); + + let wsUrl = null; + for (let i = 0; i < 30 && !wsUrl; i += 1) { + await sleep(500); + try { + const res = await fetch(`http://127.0.0.1:${CDP_PORT}/json/version`); + wsUrl = (await res.json()).webSocketDebuggerUrl; + } catch (e) { /* 还没起来 */ } + } + if (!wsUrl) { console.error('Chrome 未启动'); process.exit(1); } + + const browser = await CDP.connect(wsUrl); + const { result: { targetId } } = await browser.send('Target.createTarget', { url: 'about:blank' }); + const { result: { sessionId } } = await browser.send('Target.attachToTarget', { targetId, flatten: true }); + const S = (method, params) => browser.send(method, params, sessionId); + await S('Page.enable'); + await S('Runtime.enable'); + await S('Emulation.setDeviceMetricsOverride', { width: 1280, height: 1400, deviceScaleFactor: 1, mobile: false }); + await S('Page.navigate', { url: base }); + await sleep(2500); + + const evalJs = async (expr) => { + const r = await S('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true }); + if (r.result?.exceptionDetails) throw new Error(JSON.stringify(r.result.exceptionDetails).slice(0, 500)); + return r.result?.result?.value; + }; + + // 初始化 mock 状态;强制走「方位示意」分支,保证 SVG 里能数到编号点 + await evalJs(`(() => { + state.analysis = ${JSON.stringify(ANALYSIS)}; + state.selected = new Set(['d1']); + state.nearby = ${JSON.stringify(NEARBY)}; + state.ai.amapKey = ''; + if (!state.config) state.config = {}; + state.config.features = { ...(state.config.features || {}), amapRoad: false }; + if (typeof gotoStep === 'function') gotoStep(3); + window.__V1 = ${JSON.stringify(V1)}; + window.__V2 = ${JSON.stringify(V2)}; + window.__ver1 = { n: 1, text: window.__V1, routePoints: null, routeMissing: [] }; + window.__ver2 = { n: 2, text: window.__V2, routePoints: null, routeMissing: [] }; + return true; + })()`); + + const readMap = `(() => ({ + points: (state.routePoints || []).length, + dots: document.querySelectorAll('#routeMap .rm-idx').length, + note: document.querySelector('#routeNote')?.textContent || '', + hasSvg: !!document.querySelector('#routeMap .route-svg'), + }))()`; + + // 1) 初版 + const first = await evalJs(`(async () => { + await refreshRouteFromText(window.__V1, null, window.__ver1); + await drawRouteMap(); + return ${readMap}; + })()`); + console.log('初版 →', first); + + // 2) 复现修复前:追加轮把 nearby 清空、不继承上一版坐标 + const broken = await evalJs(`(async () => { + state.nearby = null; + await refreshRouteFromText(window.__V2, null, null); + await drawRouteMap(); + return ${readMap}; + })()`); + console.log('修复前追加轮 →', broken); + + // 3) 修复后:nearby 沿用 + 继承上一版坐标 + const fixed = await evalJs(`(async () => { + state.nearby = ${JSON.stringify(NEARBY)}; + await refreshRouteFromText(window.__V2, window.__ver1.routePoints, window.__ver2); + await drawRouteMap(); + return ${readMap}; + })()`); + console.log('修复后追加轮 →', fixed); + + // 4) 通过真实下拉事件切回第 1 版,路线图应跟着重绘 + await evalJs(`(() => { + state.planVersions = [window.__ver1, window.__ver2]; + state.viewing = 1; + renderVersionBar(); + const sel = document.querySelector('#versionSelect'); + sel.value = 'v0'; + sel.dispatchEvent(new Event('change', { bubbles: true })); + return true; + })()`); + await sleep(400); + const switched = await evalJs(readMap); + console.log('切回第 1 版 →', switched); + + // 截图:路线图区域 + const box = await evalJs(`(() => { + const r = document.querySelector('#routeMap').getBoundingClientRect(); + return { x: r.x + scrollX, y: r.y + scrollY, width: Math.max(r.width, 640), height: Math.max(r.height, 380) }; + })()`); + const shot = await S('Page.captureScreenshot', { + format: 'png', captureBeyondViewport: true, + clip: { ...box, scale: 1 }, + }); + const shotPath = path.join(OUT, 'preview-route-followup.png'); + fs.writeFileSync(shotPath, Buffer.from(shot.result.data, 'base64')); + console.log('截图已保存 preview/preview-route-followup.png'); + + const fails = []; + if (first.dots < 2) fails.push('初版路线图点数不足'); + if (broken.dots !== 1) fails.push('未能复现「只剩一个点」(期望 1,实际 ' + broken.dots + ')'); + if (fixed.dots < 2) fails.push('修复后追加轮仍只有一个点'); + if (fixed.points <= broken.points) fails.push('修复后路线点数没有增加'); + if (switched.dots < 2) fails.push('切换版本后路线图未重绘'); + + if (fails.length) { + console.error('\n失败:' + fails.join(';')); + process.exitCode = 1; + } else { + console.log('\n✅ 端到端验收通过:追加轮仍有多个点,版本切换会重绘'); + } +})().catch((e) => { + console.error(e.stack || e.message); + process.exitCode = 1; +}).finally(() => { + if (chrome) { try { chrome.kill(); } catch (e) { /* ignore */ } } + if (server) { try { server.kill(); } catch (e) { /* ignore */ } } +});