Files
gmTouringMiniApp/src/domain/travel/local-planner.ts
T
周瑞哲 c0ed0048aa
AI Code Review / review (pull_request) Successful in 6m54s
Update planner, check-in records, location service and tests
2026-07-31 15:12:21 +08:00

726 lines
28 KiB
TypeScript
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.
import type {
Interest,
ItineraryItem,
ItinerarySource,
LocalPlanAdjustment,
Pace,
PlanningOrigin,
PlanningRecommendation,
PlanningRequest,
PlanResponse,
Theme,
TravelPreferences,
} from './types'
import type { PoiRepository, PoiResolved } from '@/domain/poi'
import { classifyDurationFit } from './duration-gap'
import { normalizeTravelPreferences, TravelValidationError } from './preferences'
const THEME_TAG_WEIGHTS: Record<Theme, Readonly<Record<string, number>>> = {
亲子: { family_friendly: 34, science_learning: 14, indoor_venue: 6 },
情侣: { photo_spot: 28, nature_view: 18, pastoral_scenery: 12 },
朋友: { outdoor_leisure: 18, photo_spot: 16, culture_experience: 10 },
银发: { walking: 18, city_park: 14, culture_experience: 12, indoor_venue: 8 },
研学: { science_learning: 34, culture_experience: 20, nature_view: 8 },
}
const INTEREST_TAG_WEIGHTS: Record<Interest, Readonly<Record<string, number>>> = {
自然风光: { nature_view: 36, pastoral_scenery: 28, city_park: 18, outdoor_leisure: 12 },
文化场馆: { culture_experience: 36, indoor_venue: 22 },
生态科普: { science_learning: 38, nature_view: 16, pastoral_scenery: 12 },
美食: {},
摄影: { photo_spot: 42, nature_view: 12, pastoral_scenery: 12 },
}
const CATEGORY_ACTIVITY: Readonly<Record<string, string>> = {
cultural_venue: '参观场馆并体验公共文化空间',
urban_park: '漫步公园,按现场开放区域游览',
pastoral_leisure: '体验田园景观与休闲活动',
heritage_landmark: '探访人文地标并了解本地文化',
outdoor_greenway: '沿绿道轻量步行并欣赏沿途景观',
}
const CATEGORY_BASE_MINUTES: Readonly<Record<string, number>> = {
cultural_venue: 75,
urban_park: 65,
pastoral_leisure: 90,
heritage_landmark: 55,
outdoor_greenway: 85,
}
const PACE_FACTOR: Record<Pace, number> = {
relaxed: 1.15,
moderate: 1,
compact: 0.8,
}
function includesNegativeMention(text: string, term: string): boolean {
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return new RegExp(`(?:不要|不想|避免|无需|别|不去|不安排).{0,3}${escaped}`).test(text)
}
function includesPositiveMention(text: string, terms: readonly string[]): boolean {
return terms.some(term => text.includes(term) && !includesNegativeMention(text, term))
}
function preferenceSignals(preferences: TravelPreferences): {
indoorOnly: boolean
outdoorOnly: boolean
avoidClimbing: boolean
} {
const text = preferences.extraRequirements.replace(/\s+/g, '')
const rejectsIndoor = includesNegativeMention(text, '室内')
const rejectsOutdoor = includesNegativeMention(text, '户外')
return {
indoorOnly: rejectsOutdoor || includesPositiveMention(text, ['室内', '雨天', '避雨']),
outdoorOnly: rejectsIndoor || includesPositiveMention(text, ['户外', '室外']),
avoidClimbing: ['不爬山', '不要爬山', '不登山', '体力不好', '婴儿车'].some(term => text.includes(term)),
}
}
function tagScore(poi: PoiResolved, weights: Readonly<Record<string, number>>): number {
return poi.tagCodes.reduce((total, tagCode) => total + (weights[tagCode] ?? 0), 0)
}
function scorePoi(poi: PoiResolved, preferences: TravelPreferences): number {
let score = poi.recommendationIndex * 10
preferences.themes.forEach((theme) => {
score += tagScore(poi, THEME_TAG_WEIGHTS[theme])
})
preferences.interests.forEach((interest) => {
score += tagScore(poi, INTEREST_TAG_WEIGHTS[interest])
})
if (preferences.interests.includes('文化场馆') && poi.categoryCode === 'cultural_venue')
score += 30
if (preferences.interests.includes('自然风光') && ['urban_park', 'pastoral_leisure', 'outdoor_greenway'].includes(poi.categoryCode))
score += 16
if (preferences.children > 0 && poi.tagCodes.includes('family_friendly'))
score += 24
if (preferences.themes.includes('银发') && poi.categoryCode === 'outdoor_greenway')
score -= 18
return score
}
function isHighIntensity(poi: PoiResolved): boolean {
return poi.categoryCode === 'outdoor_greenway' || /大顶岭|森林/.test(poi.name)
}
function getCandidates(preferences: TravelPreferences, repository: PoiRepository): PoiResolved[] {
const signals = preferenceSignals(preferences)
const allPois = repository.getPublishedPois()
const candidates = allPois.filter((poi) => {
if (signals.indoorOnly && !poi.tagCodes.includes('indoor_venue'))
return false
if (signals.outdoorOnly && poi.tagCodes.includes('indoor_venue'))
return false
if (signals.avoidClimbing && isHighIntensity(poi))
return false
return true
})
return candidates
}
function roundToFive(value: number): number {
return Math.max(5, Math.round(value / 5) * 5)
}
function activityMinutes(poi: PoiResolved, pace: Pace): number {
const base = CATEGORY_BASE_MINUTES[poi.categoryCode] ?? 60
return roundToFive(base * PACE_FACTOR[pace])
}
function distanceKm(from: { longitude: number, latitude: number }, to: { longitude: number, latitude: number }): number {
const toRadians = (degrees: number) => degrees * Math.PI / 180
const deltaLatitude = toRadians(to.latitude - from.latitude)
const deltaLongitude = toRadians(to.longitude - from.longitude)
const fromLatitude = toRadians(from.latitude)
const toLatitude = toRadians(to.latitude)
const a = Math.sin(deltaLatitude / 2) ** 2
+ Math.cos(fromLatitude) * Math.cos(toLatitude) * Math.sin(deltaLongitude / 2) ** 2
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
}
function normalizePlanningOrigin(origin?: PlanningOrigin): PlanningOrigin | null {
if (!origin
|| origin.coordinateSystem !== 'GCJ02'
|| !Number.isFinite(origin.longitude)
|| !Number.isFinite(origin.latitude)
|| origin.longitude < -180
|| origin.longitude > 180
|| origin.latitude < -90
|| origin.latitude > 90) {
return null
}
return { ...origin }
}
function orderPoisFromOrigin(pois: PoiResolved[], origin: PlanningOrigin | null): PoiResolved[] {
if (!origin || pois.length < 2)
return pois
const remaining = [...pois]
const ordered: PoiResolved[] = []
let cursor: { longitude: number, latitude: number } = origin
while (remaining.length > 0) {
remaining.sort((left, right) => distanceKm(cursor, left) - distanceKm(cursor, right)
|| right.recommendationIndex - left.recommendationIndex
|| left.id.localeCompare(right.id, 'en'))
const next = remaining.shift()
if (!next)
break
ordered.push(next)
cursor = next
}
return ordered
}
function orderCustomPois(
pois: PoiResolved[],
preferences: TravelPreferences,
origin: PlanningOrigin | null,
): PoiResolved[] {
if (pois.length < 2)
return [...pois]
const remaining = [...pois]
const first = origin
? orderPoisFromOrigin(remaining, origin)[0]
: [...remaining].sort((left, right) => scorePoi(right, preferences) - scorePoi(left, preferences)
|| left.id.localeCompare(right.id, 'en'))[0]
const ordered = [first]
remaining.splice(remaining.findIndex(poi => poi.id === first.id), 1)
let cursor = first
while (remaining.length > 0) {
remaining.sort((left, right) => distanceKm(cursor, left) - distanceKm(cursor, right)
|| scorePoi(right, preferences) - scorePoi(left, preferences)
|| left.id.localeCompare(right.id, 'en'))
const next = remaining.shift()!
ordered.push(next)
cursor = next
}
return ordered
}
function transferMinutes(from: { longitude: number, latitude: number }, to: PoiResolved, preferences: TravelPreferences): number {
const distance = distanceKm(from, to)
const minutes = preferences.transport === 'walking'
? distance / 4.2 * 60
: preferences.transport === 'driving'
? distance / 28 * 60 + 5
: distance / 18 * 60 + 8
return Math.min(90, roundToFive(minutes))
}
function rankedCandidates(
candidates: PoiResolved[],
preferences: TravelPreferences,
selected: PoiResolved[],
cursor: PlanningOrigin | PoiResolved | null,
): PoiResolved[] {
return [...candidates].sort((left, right) => {
const adjustedScore = (poi: PoiResolved) => {
const categoryCount = selected.filter(item => item.categoryCode === poi.categoryCode).length
const distancePenalty = cursor ? Math.min(36, distanceKm(cursor, poi) * 2) : 0
return scorePoi(poi, preferences) - categoryCount * 14 - distancePenalty
}
return adjustedScore(right) - adjustedScore(left) || left.id.localeCompare(right.id, 'en')
})
}
function canCompleteMinimumRoute(
candidates: readonly PoiResolved[],
preferences: TravelPreferences,
cursor: PlanningOrigin | PoiResolved | null,
remainingMinutes: number,
stopsNeeded: number,
): boolean {
if (stopsNeeded <= 0)
return true
return candidates.some((poi, index) => {
const requiredMinutes = (cursor ? transferMinutes(cursor, poi, preferences) : 0)
+ activityMinutes(poi, preferences.pace)
if (requiredMinutes > remainingMinutes)
return false
return canCompleteMinimumRoute(
[...candidates.slice(0, index), ...candidates.slice(index + 1)],
preferences,
poi,
remainingMinutes - requiredMinutes,
stopsNeeded - 1,
)
})
}
function selectQuickPois(
preferences: TravelPreferences,
repository: PoiRepository,
requestedMinutes: number,
origin: PlanningOrigin | null,
): PoiResolved[] {
const remaining = getCandidates(preferences, repository)
const selected: PoiResolved[] = []
let usedMinutes = 0
let cursor: PlanningOrigin | PoiResolved | null = origin
const minimumTargetCount = Math.min(remaining.length, requestedMinutes >= 240 ? 3 : 1)
while (remaining.length > 0 && selected.length < 8) {
const ranked = rankedCandidates(remaining, preferences, selected, cursor)
const fitting = ranked.filter((poi) => {
const transfer = cursor ? transferMinutes(cursor, poi, preferences) : 0
return usedMinutes + transfer + activityMinutes(poi, preferences.pace) <= requestedMinutes
})
const next = fitting.find((poi) => {
const transfer = cursor ? transferMinutes(cursor, poi, preferences) : 0
const requiredMinutes = transfer + activityMinutes(poi, preferences.pace)
const stopsNeeded = minimumTargetCount - selected.length - 1
if (stopsNeeded <= 0)
return true
return canCompleteMinimumRoute(
remaining.filter(candidate => candidate.id !== poi.id),
preferences,
poi,
requestedMinutes - usedMinutes - requiredMinutes,
stopsNeeded,
)
}) ?? fitting[0]
if (!next)
break
const transfer = cursor ? transferMinutes(cursor, next, preferences) : 0
usedMinutes += transfer + activityMinutes(next, preferences.pace)
selected.push(next)
remaining.splice(remaining.findIndex(poi => poi.id === next.id), 1)
cursor = next
}
if (selected.length === 0 && remaining.length > 0) {
throw new TravelValidationError(
'当前起点、交通方式与所选时长无法容纳首个点位',
'quick_route_unreachable',
)
}
return selected
}
function selectCustomPois(request: PlanningRequest, repository: PoiRepository): PoiResolved[] {
const selected = request.selectedPoiIds.map(poiId => repository.getPoiById(poiId))
if (selected.some(poi => !poi))
throw new TravelValidationError('自选点位包含当前数据中不存在的 POI')
return orderCustomPois(
selected as PoiResolved[],
request.preferences,
normalizePlanningOrigin(request.origin),
)
}
function normalizeRecommendations(
recommendations: readonly PlanningRecommendation[],
request: PlanningRequest,
repository: PoiRepository,
): PlanningRecommendation[] {
if (!Array.isArray(recommendations) || recommendations.length < 1 || recommendations.length > 8)
throw new TravelValidationError('AI 推荐点位数量必须为 1 至 8 个')
const seenIds = new Set<string>()
const normalized = recommendations.map((recommendation) => {
const poiId = typeof recommendation?.poiId === 'string' ? recommendation.poiId.trim() : ''
const reason = typeof recommendation?.reason === 'string' ? recommendation.reason.trim() : ''
if (!poiId || !repository.getPoiById(poiId))
throw new TravelValidationError(`AI 推荐包含无效点位 ID${poiId || '空值'}`)
if (seenIds.has(poiId))
throw new TravelValidationError(`AI 推荐包含重复点位:${poiId}`)
if (!reason || reason.length > 300)
throw new TravelValidationError('AI 推荐理由格式无效')
seenIds.add(poiId)
return { poiId, reason }
})
if (request.mode === 'custom') {
const selectedIds = new Set(request.selectedPoiIds)
if (normalized.length !== selectedIds.size || normalized.some(item => !selectedIds.has(item.poiId)))
throw new TravelValidationError('AI 自选规划未完整保留用户选择的点位')
}
return normalized
}
function selectRecommendedPois(
recommendations: readonly PlanningRecommendation[],
request: PlanningRequest,
repository: PoiRepository,
origin: PlanningOrigin | null,
): PoiResolved[] {
const recommendedPois = recommendations.map(item => repository.getPoiById(item.poiId)!)
if (request.mode === 'custom') {
// The AI suggests an order; local nearest-neighbour ordering owns precise location use.
return origin
? orderCustomPois(recommendedPois, request.preferences, origin)
: recommendedPois
}
const remaining = origin
? orderPoisFromOrigin(recommendedPois, origin)
: [...recommendedPois]
const selected: PoiResolved[] = []
let usedMinutes = 0
let cursor: PlanningOrigin | PoiResolved | null = origin
while (remaining.length > 0 && selected.length < 8) {
const next = remaining.shift()!
const transfer = cursor ? transferMinutes(cursor, next, request.preferences) : 0
const visit = activityMinutes(next, request.preferences.pace)
if (usedMinutes + transfer + visit > request.durationMinutes)
continue
selected.push(next)
usedMinutes += transfer + visit
cursor = next
}
if (selected.length === 0 && recommendedPois.length > 0)
selected.push(remaining[0] ?? recommendedPois[0])
return selected
}
function formatClock(totalMinutes: number): string {
const hours = Math.floor(totalMinutes / 60)
const minutes = totalMinutes % 60
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
}
function buildReason(poi: PoiResolved, preferences: TravelPreferences): string {
const matchingTags = poi.tags
.filter(tag => preferences.themes.some(theme => THEME_TAG_WEIGHTS[theme][tag.code])
|| preferences.interests.some(interest => INTEREST_TAG_WEIGHTS[interest][tag.code]))
.slice(0, 2)
.map(tag => tag.name)
if (matchingTags.length > 0)
return `契合你的${matchingTags.join('、')}偏好,推荐指数 ${poi.recommendationIndex}/5。`
return `结合当前行程节奏与点位推荐指数 ${poi.recommendationIndex}/5 选入。`
}
function buildTips(poi: PoiResolved): string[] {
const tips = [poi.openingHoursText]
if (!poi.tagCodes.includes('indoor_venue'))
tips.push('户外体验受天气和现场开放情况影响,请提前确认。')
if (poi.address)
tips.push(`参考地址:${poi.address}`)
return tips.slice(0, 3)
}
function buildItems(
pois: PoiResolved[],
preferences: TravelPreferences,
origin: PlanningOrigin | null,
recommendationReasons: ReadonlyMap<string, string> = new Map(),
): ItineraryItem[] {
let cursor = 9 * 60
return pois.map((poi, index) => {
const transfer = index === 0
? origin ? transferMinutes(origin, poi, preferences) : null
: transferMinutes(pois[index - 1], poi, preferences)
if (transfer !== null)
cursor += transfer
const startTime = formatClock(cursor)
cursor += activityMinutes(poi, preferences.pace)
return {
startTime,
endTime: formatClock(cursor),
placeId: poi.id,
placeName: poi.name,
activity: CATEGORY_ACTIVITY[poi.categoryCode] ?? '按现场开放情况体验点位内容',
reason: index === 0 && origin
? `优先安排距你当前位置较近的首站;${recommendationReasons.get(poi.id) ?? buildReason(poi, preferences)}`
: recommendationReasons.get(poi.id) ?? buildReason(poi, preferences),
transferFromPreviousMinutes: transfer,
tips: buildTips(poi),
}
})
}
function isPlanningRequest(value: TravelPreferences | PlanningRequest): value is PlanningRequest {
return typeof value === 'object' && value !== null && 'mode' in value && 'preferences' in value
}
export function normalizePlanningRequest(
input: PlanningRequest,
repository: PoiRepository,
): PlanningRequest {
if (!input || (input.mode !== 'quick' && input.mode !== 'custom'))
throw new TravelValidationError('规划模式无效')
if (!Number.isInteger(input.durationMinutes)
|| input.durationMinutes < 120
|| input.durationMinutes > 600
|| input.durationMinutes % 30 !== 0) {
throw new TravelValidationError('规划时长必须为 120 至 600 分钟,并以 30 分钟为步进')
}
if (!Array.isArray(input.selectedPoiIds))
throw new TravelValidationError('自选点位格式无效')
const selectedPoiIds = input.selectedPoiIds.map(poiId => typeof poiId === 'string' ? poiId.trim() : '')
if (input.mode === 'quick' && selectedPoiIds.length > 0)
throw new TravelValidationError('快速规划不能预选点位')
if (input.mode === 'custom' && (selectedPoiIds.length < 2 || selectedPoiIds.length > 8))
throw new TravelValidationError('自选规划必须选择 2 至 8 个点位')
if (new Set(selectedPoiIds).size !== selectedPoiIds.length)
throw new TravelValidationError('自选点位不能重复')
if (input.mode === 'custom') {
selectedPoiIds.forEach((poiId) => {
if (!poiId || !repository.getPoiById(poiId))
throw new TravelValidationError(`自选点位 ID 无效:${poiId || '空值'}`)
})
}
return {
mode: input.mode,
preferences: normalizeTravelPreferences(input.preferences),
durationMinutes: input.durationMinutes,
selectedPoiIds,
...(normalizePlanningOrigin(input.origin) ? { origin: normalizePlanningOrigin(input.origin)! } : {}),
}
}
function legacyPlanningRequest(
preferences: TravelPreferences,
planningOrigin?: PlanningOrigin,
): PlanningRequest {
return {
mode: 'quick',
preferences,
durationMinutes: preferences.duration === 'half_day' ? 240 : 480,
selectedPoiIds: [],
...(planningOrigin ? { origin: planningOrigin } : {}),
}
}
function buildSources(pois: PoiResolved[]): ItinerarySource[] {
return pois.map(poi => ({
placeId: poi.id,
sourceName: poi.dataSource,
sourceUrl: poi.sourceReference ?? '',
updatedAt: poi.updatedAt,
}))
}
function stableHash(value: string): string {
let hash = 0x811C9DC5
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index)
hash = Math.imul(hash, 0x01000193)
}
return (hash >>> 0).toString(36)
}
function conversationId(request: PlanningRequest, datasetVersion: string): string {
return `local-poc-${stableHash(JSON.stringify({
datasetVersion,
preferences: request.preferences,
mode: request.mode,
durationMinutes: request.durationMinutes,
selectedPoiIds: request.selectedPoiIds,
usesOrigin: Boolean(request.origin),
}))}`
}
function budgetText(preferences: TravelPreferences): string {
const labels = { economy: '经济型', standard: '标准型', quality: '品质型' } as const
return `${labels[preferences.budgetLevel]}偏好;POC 未接入实时票价和交通计费,请以各点位及导航平台当日信息为准。`
}
export function createLocalPlan(
input: TravelPreferences | PlanningRequest,
repository: PoiRepository,
existingConversationId?: string,
planningOrigin?: PlanningOrigin,
): PlanResponse {
const request = normalizePlanningRequest(
isPlanningRequest(input)
? input
: legacyPlanningRequest(normalizeTravelPreferences(input), planningOrigin),
repository,
)
const preferences = request.preferences
const origin = normalizePlanningOrigin(request.origin)
const pois = request.mode === 'custom'
? selectCustomPois(request, repository)
: selectQuickPois(preferences, repository, request.durationMinutes, origin)
if (pois.length === 0)
throw new Error('当前 POI 数据中没有可用于规划的地点')
const items = buildItems(pois, preferences, origin)
const lastEnd = items[items.length - 1].endTime.split(':').map(Number)
const totalMinutes = lastEnd[0] * 60 + lastEnd[1] - 9 * 60
const durationLabel = preferences.duration === 'half_day' ? '半日' : '一日'
return {
conversationId: existingConversationId ?? conversationId(request, repository.getDatasetMeta().datasetVersion),
assistantMessage: origin
? '已结合你的当前位置,使用本机 POI 数据生成确定性 POC 行程。'
: '已使用本机 POI 数据生成确定性 POC 行程。',
itinerary: {
title: `光明区${durationLabel}探索路线`,
summary: `根据你的主题、兴趣和节奏,从当前已发布的光明区 POI 中选出 ${items.length} 个点位${origin ? ',并从距当前位置较近的点位开始编排' : ''}。`,
totalMinutes,
planningMode: request.mode,
requestedMinutes: request.durationMinutes,
durationFitStatus: classifyDurationFit(request.durationMinutes, totalMinutes),
startsFromCurrentLocation: Boolean(origin),
estimatedCostText: budgetText(preferences),
items,
notes: [
...(origin ? ['已使用本次前台定位优化首站和游览顺序;精确坐标不会写入行程缓存。'] : []),
'本地 POC 未接入实时路况、票价和预约库存,点位间交通时间仅按直线距离估算。',
repository.getDatasetMeta().disclaimer,
],
sources: buildSources(pois),
},
source: 'local_poc',
}
}
export function createRecommendedPlan(
input: PlanningRequest,
recommendations: readonly PlanningRecommendation[],
repository: PoiRepository,
assistantMessage: string,
source: 'remote_mock' | 'remote_ai',
existingConversationId?: string,
): PlanResponse {
const request = normalizePlanningRequest(input, repository)
const normalizedRecommendations = normalizeRecommendations(recommendations, request, repository)
const origin = normalizePlanningOrigin(request.origin)
const pois = selectRecommendedPois(normalizedRecommendations, request, repository, origin)
if (pois.length === 0)
throw new TravelValidationError('AI 推荐点位无法在所选时长内完成,请延长时长或调整偏好')
const recommendationReasons = new Map(normalizedRecommendations.map(item => [item.poiId, item.reason]))
const items = buildItems(pois, request.preferences, origin, recommendationReasons)
const lastEnd = items[items.length - 1].endTime.split(':').map(Number)
const totalMinutes = lastEnd[0] * 60 + lastEnd[1] - 9 * 60
const durationLabel = request.preferences.duration === 'half_day' ? '半日' : '一日'
const isRealAI = source === 'remote_ai'
return {
conversationId: existingConversationId ?? `remote-ai-${stableHash(JSON.stringify({
datasetVersion: repository.getDatasetMeta().datasetVersion,
request: {
mode: request.mode,
preferences: request.preferences,
durationMinutes: request.durationMinutes,
selectedPoiIds: request.selectedPoiIds,
usesOrigin: Boolean(origin),
},
recommendedPoiIds: normalizedRecommendations.map(item => item.poiId),
}))}`,
assistantMessage: assistantMessage.trim() || (isRealAI
? '在线 AI 已从审核点位中给出推荐。'
: '远端演示服务已从审核点位中给出推荐。'),
itinerary: {
title: `光明区${durationLabel}${isRealAI ? ' AI' : '智能'}推荐路线`,
summary: `${isRealAI ? '在线 AI' : '远端演示服务'}从审核 POI 白名单中推荐了 ${items.length} 个点位,本机已结合交通、时长${origin ? '和当前位置' : ''}完成安全排程。`,
totalMinutes,
planningMode: request.mode,
requestedMinutes: request.durationMinutes,
durationFitStatus: classifyDurationFit(request.durationMinutes, totalMinutes),
startsFromCurrentLocation: Boolean(origin),
estimatedCostText: budgetText(request.preferences),
items,
notes: [
...(origin ? ['已使用本次前台定位优化首站和游览顺序;精确坐标不会写入行程缓存,也不会发送给 AI。'] : []),
`${isRealAI ? 'AI' : '远端演示服务'}仅从已审核 POI 白名单中推荐点位;路线、交通时间与时长适配由本机核算。`,
'当前未接入实时路况、票价和预约库存,点位间交通时间仅按直线距离估算。',
repository.getDatasetMeta().disclaimer,
],
sources: buildSources(pois),
},
source,
}
}
function appendRequirement(current: string, message: string): string {
const segments = current.split('').map(item => item.trim()).filter(Boolean)
if (!segments.includes(message))
segments.push(message)
return segments.join('').slice(0, 500)
}
function addUnique<T>(values: T[], value: T): void {
if (!values.includes(value))
values.push(value)
}
function adjustedPreferences(input: TravelPreferences, message: string): TravelPreferences {
const preferences = normalizeTravelPreferences(input)
const normalizedMessage = message.trim()
const result: TravelPreferences = {
...preferences,
themes: [...preferences.themes],
interests: [...preferences.interests],
childAges: [...preferences.childAges],
extraRequirements: appendRequirement(preferences.extraRequirements, normalizedMessage),
}
if (/慢一点|轻松|别太赶|少安排|少一点/.test(normalizedMessage))
result.pace = 'relaxed'
else if (/紧凑|多安排|多看|丰富一点/.test(normalizedMessage))
result.pace = 'compact'
if (/半日|半天/.test(normalizedMessage))
result.duration = 'half_day'
else if (/一日|一天|全天/.test(normalizedMessage))
result.duration = 'full_day'
if (includesPositiveMention(normalizedMessage, ['亲子', '儿童', '孩子']))
addUnique(result.themes, '亲子')
if (includesPositiveMention(normalizedMessage, ['研学']))
addUnique(result.themes, '研学')
if (includesPositiveMention(normalizedMessage, ['自然', '公园', '户外']))
addUnique(result.interests, '自然风光')
if (includesPositiveMention(normalizedMessage, ['文化', '场馆', '室内']))
addUnique(result.interests, '文化场馆')
if (includesPositiveMention(normalizedMessage, ['科普']))
addUnique(result.interests, '生态科普')
if (includesPositiveMention(normalizedMessage, ['摄影', '拍照']))
addUnique(result.interests, '摄影')
if (includesPositiveMention(normalizedMessage, ['步行', '走路']))
result.transport = 'walking'
else if (includesPositiveMention(normalizedMessage, ['自驾', '开车']))
result.transport = 'driving'
else if (includesPositiveMention(normalizedMessage, ['公交', '公共交通', '地铁']))
result.transport = 'public_transport'
return normalizeTravelPreferences(result)
}
export function adjustLocalPlan(
currentPlan: PlanResponse,
currentPreferences: TravelPreferences,
message: string,
repository: PoiRepository,
planningOrigin?: PlanningOrigin,
currentRequest?: PlanningRequest,
): LocalPlanAdjustment {
const normalizedMessage = message.trim()
if (!normalizedMessage)
throw new TravelValidationError('请填写需要调整的内容')
if (normalizedMessage.length > 500)
throw new TravelValidationError('调整内容不能超过 500 字')
const preferences = adjustedPreferences(currentPreferences, normalizedMessage)
const requestedMinutes = /半日|半天/.test(normalizedMessage)
? 240
: /一日|一天|全天/.test(normalizedMessage)
? 480
: currentPlan.itinerary.requestedMinutes
const request = normalizePlanningRequest(currentRequest ?? {
mode: currentPlan.itinerary.planningMode,
preferences,
durationMinutes: requestedMinutes,
selectedPoiIds: currentPlan.itinerary.planningMode === 'custom'
? currentPlan.itinerary.items.map(item => item.placeId)
: [],
...(planningOrigin ? { origin: planningOrigin } : {}),
}, repository)
request.preferences = preferences
request.durationMinutes = requestedMinutes
const plan = createLocalPlan(request, repository, currentPlan.conversationId)
plan.assistantMessage = '已按你的补充要求重新编排本地 POC 行程。'
plan.changeSummary = `已根据“${normalizedMessage}”更新相关安排。`
return { preferences, plan, request }
}