Files
tripsense/tools/test-route-map-followup.cjs
T
tamakyi 1f902bc49b 修复 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 说明
2026-11-06 09:58:06 +08:00

163 lines
7.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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);
}