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>> = { 亲子: { 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>> = { 自然风光: { 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> = { cultural_venue: '参观场馆并体验公共文化空间', urban_park: '漫步公园,按现场开放区域游览', pastoral_leisure: '体验田园景观与休闲活动', heritage_landmark: '探访人文地标并了解本地文化', outdoor_greenway: '沿绿道轻量步行并欣赏沿途景观', } const CATEGORY_BASE_MINUTES: Readonly> = { cultural_venue: 75, urban_park: 65, pastoral_leisure: 90, heritage_landmark: 55, outdoor_greenway: 85, } const PACE_FACTOR: Record = { 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>): 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() 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 = 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(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 } }