Initial commit: gmTouringMiniApp project

This commit is contained in:
周瑞哲
2026-07-30 16:04:34 +08:00
commit ebcae02d35
201 changed files with 49545 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
import type { Duration, DurationFitStatus } from './types'
const REQUESTED_DURATION_MINUTES: Record<Duration, number> = {
half_day: 4 * 60,
full_day: 8 * 60,
}
const SIGNIFICANT_GAP_RATIO = 0.25
export function classifyDurationFit(requestedMinutes: number, plannedMinutes: number): DurationFitStatus {
if (!Number.isFinite(requestedMinutes) || requestedMinutes <= 0
|| !Number.isFinite(plannedMinutes) || plannedMinutes <= 0) {
return 'fits'
}
if (plannedMinutes > requestedMinutes)
return 'overflow'
if (requestedMinutes - plannedMinutes >= requestedMinutes * SIGNIFICANT_GAP_RATIO)
return 'underfilled'
return 'fits'
}
export function hasSignificantDurationGap(duration: Duration, plannedMinutes: number): boolean {
if (!Number.isFinite(plannedMinutes) || plannedMinutes <= 0)
return false
const requestedMinutes = REQUESTED_DURATION_MINUTES[duration]
return Math.abs(plannedMinutes - requestedMinutes) >= requestedMinutes * SIGNIFICANT_GAP_RATIO
}
+6
View File
@@ -0,0 +1,6 @@
export * from './duration-gap'
export * from './local-planner'
export * from './plan-validation'
export * from './poi-id'
export * from './preferences'
export * from './types'
+674
View File
@@ -0,0 +1,674 @@
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 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
while (remaining.length > 0 && selected.length < 8) {
const ranked = rankedCandidates(remaining, preferences, selected, cursor)
const next = ranked.find((poi) => {
const transfer = cursor ? transferMinutes(cursor, poi, preferences) : 0
return usedMinutes + transfer + activityMinutes(poi, preferences.pace) <= requestedMinutes
})
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
}
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 = [...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
}
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 }
}
+201
View File
@@ -0,0 +1,201 @@
import type {
DurationFitStatus,
Itinerary,
ItineraryItem,
ItinerarySource,
PlanningMode,
PlanResponse,
TravelAssistantSource,
} from './types'
import type { PoiRepository } from '@/domain/poi'
import { resolveCanonicalPoiId } from './poi-id'
import { TravelValidationError } from './preferences'
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function readString(
value: unknown,
fieldName: string,
options: { allowEmpty?: boolean, maxLength?: number } = {},
): string {
if (typeof value !== 'string')
throw new TravelValidationError(`${fieldName}格式无效`)
const result = value.trim()
if (!options.allowEmpty && !result)
throw new TravelValidationError(`${fieldName}不能为空`)
if (options.maxLength && result.length > options.maxLength)
throw new TravelValidationError(`${fieldName}内容过长`)
return result
}
function readStringArray(value: unknown, fieldName: string, maxItems: number): string[] {
if (!Array.isArray(value) || value.length > maxItems)
throw new TravelValidationError(`${fieldName}格式无效`)
return value.map(item => readString(item, fieldName, { maxLength: 300 }))
}
function readInteger(value: unknown, fieldName: string, min: number, max: number): number {
if (!Number.isInteger(value) || (value as number) < min || (value as number) > max)
throw new TravelValidationError(`${fieldName}格式无效`)
return value as number
}
function readPlanningMode(value: unknown): PlanningMode {
if (value !== 'quick' && value !== 'custom')
throw new TravelValidationError('规划模式无效')
return value
}
function readDurationFitStatus(value: unknown): DurationFitStatus {
if (value !== 'fits' && value !== 'underfilled' && value !== 'overflow')
throw new TravelValidationError('行程时长适配状态无效')
return value
}
function readBoolean(value: unknown, fieldName: string): boolean {
if (typeof value !== 'boolean')
throw new TravelValidationError(`${fieldName}格式无效`)
return value
}
function readClock(value: unknown, fieldName: string): string {
const result = readString(value, fieldName)
const match = /^(\d{2}):(\d{2})$/.exec(result)
if (!match || Number(match[1]) > 47 || Number(match[2]) > 59)
throw new TravelValidationError(`${fieldName}必须为 HH:mm`)
return result
}
function minutesFromClock(value: string): number {
const [hours, minutes] = value.split(':').map(Number)
return hours * 60 + minutes
}
function normalizeItem(value: unknown, repository: PoiRepository): ItineraryItem {
if (!isRecord(value))
throw new TravelValidationError('行程地点格式无效')
const rawPlaceId = readString(value.placeId, '地点 ID')
const placeId = resolveCanonicalPoiId(rawPlaceId, repository)
if (!placeId)
throw new TravelValidationError(`行程包含无法映射的地点 ID${rawPlaceId}`)
const poi = repository.getPoiById(placeId)
if (!poi)
throw new TravelValidationError(`行程地点已失效:${placeId}`)
const startTime = readClock(value.startTime, '开始时间')
const endTime = readClock(value.endTime, '结束时间')
if (minutesFromClock(endTime) <= minutesFromClock(startTime))
throw new TravelValidationError(`行程地点时间无效:${poi.name}`)
const transfer = value.transferFromPreviousMinutes
if (transfer !== null && (!Number.isInteger(transfer) || (transfer as number) < 0 || (transfer as number) > 240))
throw new TravelValidationError(`交通时间格式无效:${poi.name}`)
return {
startTime,
endTime,
placeId,
placeName: poi.name,
activity: readString(value.activity, '游玩安排', { maxLength: 300 }),
reason: readString(value.reason, '推荐理由', { maxLength: 500 }),
transferFromPreviousMinutes: transfer as number | null,
tips: readStringArray(value.tips, '出行提示', 10),
}
}
function normalizeSource(value: unknown, repository: PoiRepository): ItinerarySource {
if (!isRecord(value))
throw new TravelValidationError('行程来源格式无效')
const rawPlaceId = readString(value.placeId, '来源地点 ID')
const placeId = resolveCanonicalPoiId(rawPlaceId, repository)
if (!placeId)
throw new TravelValidationError(`行程来源包含无法映射的地点 ID${rawPlaceId}`)
return {
placeId,
sourceName: readString(value.sourceName, '来源名称', { maxLength: 300 }),
sourceUrl: readString(value.sourceUrl, '来源链接', { allowEmpty: true, maxLength: 1000 }),
updatedAt: readString(value.updatedAt, '来源更新时间', { maxLength: 100 }),
}
}
function normalizeItinerary(value: unknown, repository: PoiRepository): Itinerary {
if (!isRecord(value))
throw new TravelValidationError('行程格式无效')
if (!Array.isArray(value.items) || value.items.length < 1 || value.items.length > 8)
throw new TravelValidationError('行程地点数量必须为 1 至 8 个')
if (!Array.isArray(value.sources))
throw new TravelValidationError('行程来源格式无效')
const items = value.items.map(item => normalizeItem(item, repository))
const itemIds = new Set<string>()
let previousEnd = -1
for (const item of items) {
if (itemIds.has(item.placeId))
throw new TravelValidationError(`行程地点重复:${item.placeName}`)
itemIds.add(item.placeId)
const start = minutesFromClock(item.startTime)
if (start < previousEnd)
throw new TravelValidationError('行程时间顺序无效')
previousEnd = minutesFromClock(item.endTime)
}
const sourcesById = new Map<string, ItinerarySource>()
value.sources.forEach((source) => {
const normalized = normalizeSource(source, repository)
if (!itemIds.has(normalized.placeId))
throw new TravelValidationError(`行程来源包含未使用地点:${normalized.placeId}`)
if (!sourcesById.has(normalized.placeId))
sourcesById.set(normalized.placeId, normalized)
})
itemIds.forEach((placeId) => {
if (!sourcesById.has(placeId))
throw new TravelValidationError(`行程地点缺少来源:${placeId}`)
})
return {
title: readString(value.title, '行程标题', { maxLength: 200 }),
summary: readString(value.summary, '行程摘要', { maxLength: 1000 }),
totalMinutes: readInteger(value.totalMinutes, '行程总时长', 1, 2880),
planningMode: readPlanningMode(value.planningMode),
requestedMinutes: readInteger(value.requestedMinutes, '用户选择时长', 120, 600),
durationFitStatus: readDurationFitStatus(value.durationFitStatus),
startsFromCurrentLocation: readBoolean(value.startsFromCurrentLocation, '当前位置出发标记'),
estimatedCostText: readString(value.estimatedCostText, '费用说明', { maxLength: 500 }),
items,
notes: readStringArray(value.notes, '行程说明', 20),
sources: [...sourcesById.values()],
}
}
export function normalizePlanResponse(
value: unknown,
repository: PoiRepository,
sourceOverride?: TravelAssistantSource,
): PlanResponse {
if (!isRecord(value))
throw new TravelValidationError('行程响应格式无效')
const source = sourceOverride ?? value.source
if (source !== 'local_poc' && source !== 'remote_mock' && source !== 'remote_ai')
throw new TravelValidationError('行程生成来源无效')
const changeSummary = value.changeSummary
if (changeSummary !== undefined && changeSummary !== null && typeof changeSummary !== 'string')
throw new TravelValidationError('调整摘要格式无效')
return {
conversationId: readString(value.conversationId, '会话 ID', { maxLength: 200 }),
assistantMessage: readString(value.assistantMessage, '助手消息', { maxLength: 1000 }),
itinerary: normalizeItinerary(value.itinerary, repository),
changeSummary: typeof changeSummary === 'string' ? changeSummary.trim() : changeSummary,
source,
}
}
export function clonePlanResponse(plan: PlanResponse, repository: PoiRepository): PlanResponse {
return normalizePlanResponse(plan, repository)
}
+25
View File
@@ -0,0 +1,25 @@
import type { PoiRepository } from '@/domain/poi'
export const LEGACY_PLACE_ID_TO_POI_ID: Readonly<Record<string, string>> = Object.freeze({
'guangming-science-park': 'poi_amap_b0j2jc4n0w',
'guangming-happy-farm': 'poi_happy_pastoral',
'guangming-farm-grand-view': 'poi_farm_grand_view',
'guangming-hongqiao-park': 'poi_hongqiao_park',
'guangming-dadingling-greenway': 'poi_dadingling_greenway',
'guangming-culture-art-center': 'poi_gm_culture_center',
'guangming-honghua-park': 'poi_honghuashan_park',
'guangming-zuoan-tech-park': 'poi_amap_b0g0kzrt5s',
'guangming-minghu-park': 'poi_amap_b0fffwu1l5',
})
export function resolveCanonicalPoiId(placeId: string, repository: PoiRepository): string | null {
const normalized = placeId.trim()
if (!normalized)
return null
if (repository.getPoiById(normalized))
return normalized
const mappedId = LEGACY_PLACE_ID_TO_POI_ID[normalized]
return mappedId && repository.getPoiById(mappedId) ? mappedId : null
}
+118
View File
@@ -0,0 +1,118 @@
import {
type BudgetLevel,
type Duration,
type Interest,
type Pace,
type Theme,
type Transport,
TRAVEL_BUDGET_LEVELS,
TRAVEL_DURATIONS,
TRAVEL_INTERESTS,
TRAVEL_PACES,
TRAVEL_THEMES,
TRAVEL_TRANSPORTS,
type TravelPreferences,
} from './types'
const DESTINATION = '深圳市光明区' as const
export class TravelValidationError extends Error {
constructor(message: string) {
super(message)
this.name = 'TravelValidationError'
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isEnumValue<T extends string>(value: unknown, allowed: readonly T[]): value is T {
return typeof value === 'string' && allowed.includes(value as T)
}
function normalizeEnumArray<T extends string>(
value: unknown,
allowed: readonly T[],
fieldName: string,
): T[] {
if (!Array.isArray(value) || value.some(item => !isEnumValue(item, allowed)))
throw new TravelValidationError(`${fieldName}包含无效选项`)
const selected = new Set(value as T[])
return allowed.filter(item => selected.has(item))
}
function normalizeInteger(value: unknown, fieldName: string, min: number, max: number): number {
if (!Number.isInteger(value) || (value as number) < min || (value as number) > max)
throw new TravelValidationError(`${fieldName}必须是 ${min}${max} 的整数`)
return value as number
}
export function normalizeTravelPreferences(value: unknown): TravelPreferences {
if (!isRecord(value))
throw new TravelValidationError('出行偏好格式无效')
if (value.destination !== DESTINATION)
throw new TravelValidationError('当前仅支持深圳市光明区')
const themes = normalizeEnumArray<Theme>(value.themes, TRAVEL_THEMES, '出行主题')
const interests = normalizeEnumArray<Interest>(value.interests, TRAVEL_INTERESTS, '特色偏好')
if (themes.length === 0 && interests.length === 0)
throw new TravelValidationError('主题或特色偏好至少选择一项')
if (!isEnumValue<Duration>(value.duration, TRAVEL_DURATIONS))
throw new TravelValidationError('游玩时长无效')
if (!isEnumValue<Pace>(value.pace, TRAVEL_PACES))
throw new TravelValidationError('行程节奏无效')
if (!isEnumValue<Transport>(value.transport, TRAVEL_TRANSPORTS))
throw new TravelValidationError('交通方式无效')
if (!isEnumValue<BudgetLevel>(value.budgetLevel, TRAVEL_BUDGET_LEVELS))
throw new TravelValidationError('预算档位无效')
const adults = normalizeInteger(value.adults, '成人数量', 0, 20)
const children = normalizeInteger(value.children, '儿童数量', 0, 20)
if (adults + children < 1)
throw new TravelValidationError('出行总人数至少为 1')
if (!Array.isArray(value.childAges) || value.childAges.length !== children)
throw new TravelValidationError('儿童年龄数量必须与儿童人数一致')
const childAges = value.childAges.map(age => normalizeInteger(age, '儿童年龄', 0, 17))
if (typeof value.extraRequirements !== 'string')
throw new TravelValidationError('补充要求格式无效')
const extraRequirements = value.extraRequirements.trim()
if (extraRequirements.length > 500)
throw new TravelValidationError('补充要求不能超过 500 字')
return {
destination: DESTINATION,
themes,
duration: value.duration,
pace: value.pace,
interests,
adults,
children,
childAges,
transport: value.transport,
budgetLevel: value.budgetLevel,
extraRequirements,
}
}
export function isTravelPreferences(value: unknown): value is TravelPreferences {
try {
normalizeTravelPreferences(value)
return true
}
catch {
return false
}
}
export function cloneTravelPreferences(preferences: TravelPreferences): TravelPreferences {
const normalized = normalizeTravelPreferences(preferences)
return {
...normalized,
themes: [...normalized.themes],
interests: [...normalized.interests],
childAges: [...normalized.childAges],
}
}
+97
View File
@@ -0,0 +1,97 @@
export const TRAVEL_THEMES = ['亲子', '情侣', '朋友', '银发', '研学'] as const
export const TRAVEL_INTERESTS = ['自然风光', '文化场馆', '生态科普', '美食', '摄影'] as const
export const TRAVEL_DURATIONS = ['half_day', 'full_day'] as const
export const TRAVEL_PACES = ['relaxed', 'moderate', 'compact'] as const
export const TRAVEL_TRANSPORTS = ['walking', 'driving', 'public_transport'] as const
export const TRAVEL_BUDGET_LEVELS = ['economy', 'standard', 'quality'] as const
export type Theme = typeof TRAVEL_THEMES[number]
export type Interest = typeof TRAVEL_INTERESTS[number]
export type Duration = typeof TRAVEL_DURATIONS[number]
export type Pace = typeof TRAVEL_PACES[number]
export type Transport = typeof TRAVEL_TRANSPORTS[number]
export type BudgetLevel = typeof TRAVEL_BUDGET_LEVELS[number]
export type TravelAssistantSource = 'local_poc' | 'remote_mock' | 'remote_ai'
export type PlanningMode = 'quick' | 'custom'
export type DurationFitStatus = 'fits' | 'underfilled' | 'overflow'
export interface PlanningOrigin {
longitude: number
latitude: number
coordinateSystem: 'GCJ02'
}
export interface TravelPreferences {
destination: '深圳市光明区'
themes: Theme[]
duration: Duration
pace: Pace
interests: Interest[]
adults: number
children: number
childAges: number[]
transport: Transport
budgetLevel: BudgetLevel
extraRequirements: string
}
export interface PlanningRequest {
mode: PlanningMode
preferences: TravelPreferences
durationMinutes: number
selectedPoiIds: string[]
origin?: PlanningOrigin
}
export interface PlanningRecommendation {
poiId: string
reason: string
}
export interface ItineraryItem {
startTime: string
endTime: string
/** Always a canonical ID resolvable by the current PoiRepository. */
placeId: string
placeName: string
activity: string
reason: string
transferFromPreviousMinutes: number | null
tips: string[]
}
export interface ItinerarySource {
/** Always a canonical ID resolvable by the current PoiRepository. */
placeId: string
sourceName: string
sourceUrl: string
updatedAt: string
}
export interface Itinerary {
title: string
summary: string
totalMinutes: number
planningMode: PlanningMode
requestedMinutes: number
durationFitStatus: DurationFitStatus
startsFromCurrentLocation: boolean
estimatedCostText: string
items: ItineraryItem[]
notes: string[]
sources: ItinerarySource[]
}
export interface PlanResponse {
conversationId: string
assistantMessage: string
itinerary: Itinerary
changeSummary?: string | null
source: TravelAssistantSource
}
export interface LocalPlanAdjustment {
preferences: TravelPreferences
plan: PlanResponse
request: PlanningRequest
}