修复 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:
2026-11-06 09:58:06 +08:00
parent 7ad86c6619
commit 1f902bc49b
6 changed files with 551 additions and 23 deletions
+109 -16
View File
@@ -897,11 +897,20 @@ const PLACE_SUFFIX = /(景区|风景区|公园|博物馆|纪念馆|展览馆|美
/** 加粗词里常见的「说明性词」:即使带后缀也不是可去的地方 */
const NOT_PLACE = /^(演示模式|需求备忘|合计|总计|小计|自驾|铁路|公共交通|高铁|客车|班车|合计|注意|说明|备注|触发条件|替换动作|若遇.*|以下.*|建议.*|推荐.*|可选.*|雨天.*|室内.*|户外.*|数据来源.*|出发前.*|门票.*|交通.*|住宿.*|预算.*|风险.*|清单.*|装备.*|证件.*|提醒.*|动作.*|政策.*|预约.*|换乘.*|停车.*|步行.*|骑行.*|拼车.*|包车.*|自驾.*)$/;
function extractPlanPlaces(text) {
function extractPlanPlaces(text, inherit) {
// inherit:上一版已定位的路线点。AI 追加轮常把点名地点改写成简称、
// 或不再重复加粗;若只按本版正文提取,这些点会全部失去坐标 → 路线图塌成孤点。
const src = String(text || '');
if (!src.trim()) return [];
const norm = (s) => 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('') + '<option value="__clear">— 清空已保存 —</option></optgroup>';
}
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');
}
});