修复 AI 追加轮后路线示意只剩一个点
根因: 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 说明
This commit is contained in:
@@ -183,18 +183,20 @@ node tools/build-admin-dv.cjs C:/tmp/datav-admin.json
|
|||||||
|
|
||||||
#### 离线回归测试
|
#### 离线回归测试
|
||||||
|
|
||||||
归属逻辑不能只靠真实网络验证——Photon / Overpass 随时会挂,挂了就没法回归。仓库里有三个**不发外部请求**的离线测试:
|
归属逻辑不能只靠真实网络验证——Photon / Overpass 随时会挂,挂了就没法回归。仓库里有**不发外部请求**的离线测试:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node tools/test-admin-lookup.cjs # 归属判定单测:同名县消歧、已知边界点、狭长县远端点
|
node tools/test-admin-lookup.cjs # 归属判定单测:同名县消歧、已知边界点、狭长县远端点
|
||||||
node tools/test-overpass-fallback.cjs # 起本地假 Overpass:验兜底路径、简称归一、200+HTML 错误页
|
node tools/test-overpass-fallback.cjs # 起本地假 Overpass:验兜底路径、简称归一、200+HTML 错误页
|
||||||
node tools/test-grouping-regression.cjs # 起本地假 Photon:20 个候选的完整分组回归
|
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
|
```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**,否则命中第一轮缓存。
|
写这类测试时注意:**`cfg.SEARCH.maxCandidates` 缺省会静默返回 0 候选**(`selected.length < undefined` 恒假);**第二轮跑要清 `require.cache` 或换 origin**,否则命中第一轮缓存。
|
||||||
@@ -443,6 +445,25 @@ rgh status # 确认已登录
|
|||||||
- 无 Key 时降级为按真实经纬度等距投影(经度按 `cos(lat)` 修正)的示意图。
|
- 无 Key 时降级为按真实经纬度等距投影(经度按 `cos(lat)` 修正)的示意图。
|
||||||
- 地图服务商只使用合规白名单(高德 / 腾讯 / 百度 / 天地图),不使用 Google / Apple / Mapbox / OSM 直连瓦片。
|
- 地图服务商只使用合规白名单(高德 / 腾讯 / 百度 / 天地图),不使用 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」)点「继续优化」即可迭代。
|
方案不是一次性的。生成完成后,输出框下方会出现输入条,写一句要求(「第二天改成室内为主」「总预算压到 1200」)点「继续优化」即可迭代。
|
||||||
@@ -451,6 +472,8 @@ rgh status # 确认已登录
|
|||||||
- 输出区顶部显示「第 N 版」,右侧「回到初版」可退回第一版做对比。
|
- 输出区顶部显示「第 N 版」,右侧「回到初版」可退回第一版做对比。
|
||||||
- 演示模式下规则引擎无法真正理解增量需求,会在开头注明并重新生成通用方案,不会假装已经改好。
|
- 演示模式下规则引擎无法真正理解增量需求,会在开头注明并重新生成通用方案,不会假装已经改好。
|
||||||
- 上一版超过 12000 字会被截断,避免把上下文撑爆;`previous` 只用于多轮消息,不会重复塞进上下文 JSON。
|
- 上一版超过 12000 字会被截断,避免把上下文撑爆;`previous` 只用于多轮消息,不会重复塞进上下文 JSON。
|
||||||
|
- 追加轮**沿用第一轮查到的 `nearby` 周边地点**(已选目的地没变时),并把它继续放进上下文 JSON;清空会让第二版失去可点名的地点,路线图塌成一个点。详见上文「路线图 → 追加轮不能塌成一个点」。
|
||||||
|
- 每一版都存自己的 `routePoints`,切换版本 / 回到初版 / 载入保存方案时路线图同步重绘;`previous` 用 `currentPlanText()`,保证「正在看哪一版就基于哪一版改」。
|
||||||
|
|
||||||
### AI 接口自检
|
### AI 接口自检
|
||||||
|
|
||||||
@@ -743,9 +766,12 @@ tripsense/
|
|||||||
- **缓存失效的根因(隐蔽)**:`$('#analyzeBtn').addEventListener('click', runAnalyze)` 会把 **Event 对象**传进 `runAnalyze(force)`,而 `Boolean(event)` 恒为 `true` —— 于是每一次点击都是「强制刷新」,缓存永远不命中。改为只认严格的 `force === true`,并把监听器包成 `() => runAnalyze()`。实测:冷启动 12.8s → 第二次 518ms(24 倍),提示条正确显示;点「重新获取最新数据」后提示条正确隐藏。
|
- **缓存失效的根因(隐蔽)**:`$('#analyzeBtn').addEventListener('click', runAnalyze)` 会把 **Event 对象**传进 `runAnalyze(force)`,而 `Boolean(event)` 恒为 `true` —— 于是每一次点击都是「强制刷新」,缓存永远不命中。改为只认严格的 `force === true`,并把监听器包成 `() => runAnalyze()`。实测:冷启动 12.8s → 第二次 518ms(24 倍),提示条正确显示;点「重新获取最新数据」后提示条正确隐藏。
|
||||||
- **反向地理编码的假地名**:`reverseGeocode` 彻底失败时兜底返回「当前位置」这个**假地名**,拿它去查酒店库必然落空,还会被误判成"该数据源没数据"。新增 `resolveCityName()` 把假地名视为失败并重试一次;数据源层缓存也**不缓存**假地名。
|
- **反向地理编码的假地名**:`reverseGeocode` 彻底失败时兜底返回「当前位置」这个**假地名**,拿它去查酒店库必然落空,还会被误判成"该数据源没数据"。新增 `resolveCityName()` 把假地名视为失败并重试一次;数据源层缓存也**不缓存**假地名。
|
||||||
- **缓存分层实测**:`/api/analyze` 层缓存 30 分钟;数据源层(`lib/sources.js`)另有 Photon 分类检索 / 高德 POI / 天气 / AQI / 路网 / 铁路各自的缓存。清空后重建:`discoverCandidates` 首次 8137ms → 第二次 **0ms**;`roadMatrix` 首次 127ms → 第二次 **0ms**。
|
- **缓存分层实测**:`/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-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-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、路况仍为实时数据。
|
> 说明:上面这组截图恰好是在 Photon 公共实例全部超时(`stats.fallbackOnly=true`)时拍的,因此 Step 2 顶部能看到降级提示条——这正好是「实时源挂掉时页面怎么表现」的真实记录;天气、AQI、路况仍为实时数据。
|
||||||
|
|||||||
+3
-1
@@ -134,7 +134,9 @@ function buildMessages(payload, systemPrompt, extra = {}) {
|
|||||||
content:
|
content:
|
||||||
`在上一版方案的基础上做如下调整:${extra.followUp || '请自行提升可执行性'}\n\n` +
|
`在上一版方案的基础上做如下调整:${extra.followUp || '请自行提升可执行性'}\n\n` +
|
||||||
'要求:输出调整后的**完整**方案,保持原有 Markdown 结构(方案总览 / 逐日安排 / 天气与装备 / 交通方案 / 预算粗估 / 出发前清单 / 风险与备选)。' +
|
'要求:输出调整后的**完整**方案,保持原有 Markdown 结构(方案总览 / 逐日安排 / 天气与装备 / 交通方案 / 预算粗估 / 出发前清单 / 风险与备选)。' +
|
||||||
'不要只输出改动片段,也不要写「已按要求修改」之类的交代;与上一版冲突的地方直接改掉,并在「风险与备选」里说明取舍。',
|
'不要只输出改动片段,也不要写「已按要求修改」之类的交代;与上一版冲突的地方直接改掉,并在「风险与备选」里说明取舍。' +
|
||||||
|
'另外:上一版点名的具体地点(**加粗**的景区、餐馆、场馆等)要继续写出名称,不要退化成「某片区」「自行核实」;' +
|
||||||
|
'若调整后新增或替换了地点,请从上下文 JSON 的 nearby / shortlist 里取真实名称,并同样用 **加粗** 标出,供行程路线示意定位。',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return msgs;
|
return msgs;
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
+109
-16
@@ -897,11 +897,20 @@ const PLACE_SUFFIX = /(景区|风景区|公园|博物馆|纪念馆|展览馆|美
|
|||||||
/** 加粗词里常见的「说明性词」:即使带后缀也不是可去的地方 */
|
/** 加粗词里常见的「说明性词」:即使带后缀也不是可去的地方 */
|
||||||
const NOT_PLACE = /^(演示模式|需求备忘|合计|总计|小计|自驾|铁路|公共交通|高铁|客车|班车|合计|注意|说明|备注|触发条件|替换动作|若遇.*|以下.*|建议.*|推荐.*|可选.*|雨天.*|室内.*|户外.*|数据来源.*|出发前.*|门票.*|交通.*|住宿.*|预算.*|风险.*|清单.*|装备.*|证件.*|提醒.*|动作.*|政策.*|预约.*|换乘.*|停车.*|步行.*|骑行.*|拼车.*|包车.*|自驾.*)$/;
|
const NOT_PLACE = /^(演示模式|需求备忘|合计|总计|小计|自驾|铁路|公共交通|高铁|客车|班车|合计|注意|说明|备注|触发条件|替换动作|若遇.*|以下.*|建议.*|推荐.*|可选.*|雨天.*|室内.*|户外.*|数据来源.*|出发前.*|门票.*|交通.*|住宿.*|预算.*|风险.*|清单.*|装备.*|证件.*|提醒.*|动作.*|政策.*|预约.*|换乘.*|停车.*|步行.*|骑行.*|拼车.*|包车.*|自驾.*)$/;
|
||||||
|
|
||||||
function extractPlanPlaces(text) {
|
function extractPlanPlaces(text, inherit) {
|
||||||
|
// inherit:上一版已定位的路线点。AI 追加轮常把点名地点改写成简称、
|
||||||
|
// 或不再重复加粗;若只按本版正文提取,这些点会全部失去坐标 → 路线图塌成孤点。
|
||||||
const src = String(text || '');
|
const src = String(text || '');
|
||||||
if (!src.trim()) return [];
|
if (!src.trim()) return [];
|
||||||
const norm = (s) => String(s || '').replace(/[\s*()()【】\[\],,。.、::·\-—]/g, '').toLowerCase();
|
const norm = (s) => String(s || '').replace(/[\s*()()【】\[\],,。.、::·\-—]/g, '').toLowerCase();
|
||||||
const body = norm(src);
|
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 pool = [];
|
||||||
const push = (name, extra = {}) => {
|
const push = (name, extra = {}) => {
|
||||||
@@ -909,14 +918,31 @@ function extractPlanPlaces(text) {
|
|||||||
if (!n || n.length < 2) return;
|
if (!n || n.length < 2) return;
|
||||||
const key = norm(n);
|
const key = norm(n);
|
||||||
if (!key) return;
|
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 });
|
pool.push({ name: n, ...extra });
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1) nearby 点名地点(餐饮/景点):模型被强制点名的,名字必定出现在正文里
|
// 1) nearby 点名地点(餐饮/景点):模型被强制点名的,名字必定出现在正文里
|
||||||
const nb = state.nearby || {};
|
const nb = state.nearby || {};
|
||||||
for (const x of [...(nb.sights || []), ...(nb.food || [])]) {
|
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) 已选目的地
|
// 2) 已选目的地
|
||||||
const a = state.analysis;
|
const a = state.analysis;
|
||||||
@@ -936,6 +962,24 @@ function extractPlanPlaces(text) {
|
|||||||
push(n, { kind: '地点', from: 'bold' });
|
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 withPos = pool.map((p) => {
|
||||||
const k = norm(p.name);
|
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 点截取) */
|
/** 静态地图最多能承载的路线点数(服务端 staticMapMeta 同样按 9 点截取) */
|
||||||
const MAX_ROUTE_POINTS = 9;
|
const MAX_ROUTE_POINTS = 9;
|
||||||
|
|
||||||
@@ -1042,7 +1106,9 @@ async function drawRouteMap() {
|
|||||||
// 优先用「方案里实际安排的地点」画线路;方案还没生成时才退回已选候选
|
// 优先用「方案里实际安排的地点」画线路;方案还没生成时才退回已选候选
|
||||||
let picked = [];
|
let picked = [];
|
||||||
let label = '';
|
let label = '';
|
||||||
let missing = [];
|
// 没能定位的地点要在底图提示里如实说明;原来这里是空的局部数组,
|
||||||
|
// state.routeMissing 从没被用上,导致「有 N 个地点未能定位」永远不显示。
|
||||||
|
let missing = state.routeMissing || [];
|
||||||
let total = 0;
|
let total = 0;
|
||||||
if (state.routePoints && state.routePoints.length) {
|
if (state.routePoints && state.routePoints.length) {
|
||||||
total = state.routePoints.length;
|
total = state.routePoints.length;
|
||||||
@@ -1324,8 +1390,9 @@ async function runPlan(opts = {}) {
|
|||||||
.map((c) => ({ ...c, direction: compassCN(c.bearingDeg) }));
|
.map((c) => ({ ...c, direction: compassCN(c.bearingDeg) }));
|
||||||
if (!shortlist.length) return toast('请至少选择一个目的地', 'err');
|
if (!shortlist.length) return toast('请至少选择一个目的地', 'err');
|
||||||
const isFollowUp = Boolean(opts.followUp);
|
const isFollowUp = Boolean(opts.followUp);
|
||||||
// 追加轮把「当前这一版」交给模型当底稿;首次生成则是空的
|
// 追加轮把「用户当前正在看的这一版」交给模型当底稿;首次生成则是空的。
|
||||||
const previous = isFollowUp ? state.planText : '';
|
// 不能直接用 state.planText:查看历史版时它仍是最后一版,会基于错误底稿调整。
|
||||||
|
const previous = isFollowUp ? currentPlanText() : '';
|
||||||
|
|
||||||
// 先补「可点名的地点」:只给住宿 POI 的话,模型只能写"某某片区 + 自行核实"。
|
// 先补「可点名的地点」:只给住宿 POI 的话,模型只能写"某某片区 + 自行核实"。
|
||||||
// 这里对主目的地查一次周边餐饮 / 景点,塞进 nearby 一起交给模型。
|
// 这里对主目的地查一次周边餐饮 / 景点,塞进 nearby 一起交给模型。
|
||||||
@@ -1348,6 +1415,12 @@ async function runPlan(opts = {}) {
|
|||||||
}
|
}
|
||||||
} catch { /* 取不到就照常生成,不让它阻断流程 */ }
|
} catch { /* 取不到就照常生成,不让它阻断流程 */ }
|
||||||
}
|
}
|
||||||
|
} else if (state.nearby && shortlist.some((c) => c.name === state.nearby.area)) {
|
||||||
|
// 追加轮沿用上一轮的 nearby:已选目的地没变时,这些坐标仍然有效。
|
||||||
|
// 若在这里清空,模型上下文里就没有「可点名的地点」,第二版会退化成
|
||||||
|
// 「某片区 + 自行核实」,extractPlanPlaces 的来源 1 也跟着空 →
|
||||||
|
// 路线图只剩已选目的地(常常只有 1 个)→ 用户看到「只剩一个点」。
|
||||||
|
nearby = state.nearby;
|
||||||
}
|
}
|
||||||
// 存一份供前端提取方案里的点名地点(路线图要用它们的坐标)
|
// 存一份供前端提取方案里的点名地点(路线图要用它们的坐标)
|
||||||
state.nearby = nearby;
|
state.nearby = nearby;
|
||||||
@@ -1426,6 +1499,9 @@ async function runPlan(opts = {}) {
|
|||||||
needs: opts.followUp || '',
|
needs: opts.followUp || '',
|
||||||
text: state.planText,
|
text: state.planText,
|
||||||
at: Date.now(),
|
at: Date.now(),
|
||||||
|
// 路线点属于「这一版方案」:切版本时要用它重绘,不能停在最后一版的点
|
||||||
|
routePoints: null,
|
||||||
|
routeMissing: [],
|
||||||
});
|
});
|
||||||
renderVersionBar();
|
renderVersionBar();
|
||||||
}
|
}
|
||||||
@@ -1439,14 +1515,11 @@ async function runPlan(opts = {}) {
|
|||||||
$('#planExtra').classList.remove('hidden');
|
$('#planExtra').classList.remove('hidden');
|
||||||
// 先把方案里点名的地点解析成真实坐标,再画线路。
|
// 先把方案里点名的地点解析成真实坐标,再画线路。
|
||||||
// 这一步要等方案正文定稿,所以放在生成之后而不是之前。
|
// 这一步要等方案正文定稿,所以放在生成之后而不是之前。
|
||||||
try {
|
// 追加轮把「上一版已定位的点」作为继承池传进去:模型改写后不再重复
|
||||||
const places = extractPlanPlaces(state.planText);
|
// 加粗的地点,只要正文里还出现,就沿用真实坐标,路线图不会塌成孤点。
|
||||||
const r = await resolveRoutePoints(places);
|
const curVer = state.planVersions[state.planVersions.length - 1];
|
||||||
state.routePoints = r.points.filter((p) => p.lat != null && p.lon != null);
|
const prevVer = isFollowUp ? state.planVersions[state.planVersions.length - 2] : null;
|
||||||
state.routeMissing = r.missing || [];
|
await refreshRouteFromText(state.planText, prevVer && prevVer.routePoints, curVer);
|
||||||
} catch {
|
|
||||||
state.routePoints = null;
|
|
||||||
}
|
|
||||||
// drawRouteMap 是 async:必须 catch,否则它内部任何异常都会变成
|
// drawRouteMap 是 async:必须 catch,否则它内部任何异常都会变成
|
||||||
// "未处理的 Promise 拒绝",路线图会无声消失(之前踩过:变量未声明 → ReferenceError)
|
// "未处理的 Promise 拒绝",路线图会无声消失(之前踩过:变量未声明 → ReferenceError)
|
||||||
drawRouteMap().catch((err) => {
|
drawRouteMap().catch((err) => {
|
||||||
@@ -1514,7 +1587,7 @@ function renderVersionBar() {
|
|||||||
}).join('') + '<option value="__clear">— 清空已保存 —</option></optgroup>';
|
}).join('') + '<option value="__clear">— 清空已保存 —</option></optgroup>';
|
||||||
}
|
}
|
||||||
sel.innerHTML = html;
|
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');
|
sel.classList.remove('hidden');
|
||||||
$('#comparePlan').classList.toggle('hidden', list.length < 2);
|
$('#comparePlan').classList.toggle('hidden', list.length < 2);
|
||||||
$('#savePlan').classList.toggle('hidden', !list.length);
|
$('#savePlan').classList.toggle('hidden', !list.length);
|
||||||
@@ -1817,8 +1890,19 @@ function init() {
|
|||||||
$('#resetPlan').addEventListener('click', () => {
|
$('#resetPlan').addEventListener('click', () => {
|
||||||
if (!state.basePlan) return toast('还没有可回退的初版', 'err');
|
if (!state.basePlan) return toast('还没有可回退的初版', 'err');
|
||||||
state.planText = state.basePlan;
|
state.planText = state.basePlan;
|
||||||
|
// 指回第 1 版:paintPlan / currentPlanText / 版本下拉都以 viewing 为准,
|
||||||
|
// 若留着历史版索引,「回到初版」正文与下拉都不会跟着变。
|
||||||
|
state.viewing = state.planVersions.length ? 0 : null;
|
||||||
state.planRound = 1;
|
state.planRound = 1;
|
||||||
paintPlan();
|
paintPlan();
|
||||||
|
// 路线图也跟着回到初版
|
||||||
|
const first = state.planVersions[0];
|
||||||
|
if (first) {
|
||||||
|
state.routePoints = first.routePoints || null;
|
||||||
|
state.routeMissing = first.routeMissing || [];
|
||||||
|
drawRouteMap().catch(() => {});
|
||||||
|
}
|
||||||
|
renderVersionBar();
|
||||||
$('#outputMeta').textContent = '已回到初版';
|
$('#outputMeta').textContent = '已回到初版';
|
||||||
$('#followupCount').textContent = '会基于当前方案重写完整版本';
|
$('#followupCount').textContent = '会基于当前方案重写完整版本';
|
||||||
toast('已回到初版方案', 'ok');
|
toast('已回到初版方案', 'ok');
|
||||||
@@ -1838,6 +1922,11 @@ function init() {
|
|||||||
toggleCompare(false);
|
toggleCompare(false);
|
||||||
paintPlan();
|
paintPlan();
|
||||||
const ver = state.planVersions[i];
|
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) : ''}`;
|
$('#outputMeta').textContent = `第 ${ver.n} 版${ver.needs ? ' · ' + ver.needs.slice(0, 16) : ''}`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1847,7 +1936,9 @@ function init() {
|
|||||||
state.viewing = null;
|
state.viewing = null;
|
||||||
state.planText = s.text;
|
state.planText = s.text;
|
||||||
state.basePlan = 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;
|
state.planRound = 1;
|
||||||
toggleCompare(false);
|
toggleCompare(false);
|
||||||
$('#planOutput').classList.remove('empty');
|
$('#planOutput').classList.remove('empty');
|
||||||
@@ -1855,6 +1946,8 @@ function init() {
|
|||||||
$('#planFollowup').classList.remove('hidden');
|
$('#planFollowup').classList.remove('hidden');
|
||||||
$('#outputMeta').textContent = '已载入保存的方案';
|
$('#outputMeta').textContent = '已载入保存的方案';
|
||||||
renderVersionBar();
|
renderVersionBar();
|
||||||
|
refreshRouteFromText(s.text, null, state.planVersions[0])
|
||||||
|
.then(() => drawRouteMap().catch(() => {}));
|
||||||
toast('已载入保存的方案,可以继续调整', 'ok');
|
toast('已载入保存的方案,可以继续调整', 'ok');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 */ } }
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user