diff --git a/README.md b/README.md index 03d44d6..27b8841 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 基于 uni-app、Vue 3、TypeScript 和 Pinia 开发的微信小程序 POC,用于验证深圳市光明区文旅资源地图探索、POI 数字化展示、文旅问答式偏好收集和行程规划。 -当前范围包括地图拖动缩放、POI 分类、marker 与摘要联动、用户主动授权后的前台实时定位、POI 详情、AI 文旅助手交互和推荐行程展示。POC 默认使用随包数据与本地确定性规则生成游览顺序;有定位快照时会优先安排较近首站,但不是道路级实时导航,也不包含打卡、登录或个人中心。 +当前范围包括地图拖动缩放、POI 分类、marker 与摘要联动、用户主动授权后的前台实时定位、POI 详情、AI 文旅助手交互和推荐行程展示。路线规划支持基于当前位置的快速规划,以及用户自选 2–8 个 POI、2–10 小时时长的自定义规划;本地规划器按时间预算动态排程,配置服务端后可由受控 AI 在审核白名单内推荐点位与顺序。当前不是道路级实时导航,也不包含打卡、登录或个人中心。 ## 微信开发者工具 @@ -58,9 +58,9 @@ src/ └── static/ # 随包静态资源 ``` -不配置 `VITE_TRAVEL_ASSISTANT_API_BASE_URL` 时,小程序始终使用本地确定性 POC 规划器。若后续接入远程 AI,只允许在 `.env.local` 配置受控后端地址,并在微信公众平台配置合法请求域名;API Key、模型凭证和高德 Web Service Key 均不得进入客户端环境变量或构建产物。 +不配置 `VITE_TRAVEL_ASSISTANT_API_BASE_URL` 时,小程序使用本地确定性 POC 规划器。配置受控后端地址后,服务端 AI 只接收偏好和不含坐标的 POI 白名单,返回推荐 ID、理由和顺序;当前位置、交通估算、时长适配及最终路线仍由小程序本机计算。服务端默认 `LLM_MODE=mock`,只有部署时设置 `LLM_MODE=real` 及模型凭据才会标记为真实 AI。微信公众平台还需配置 HTTPS 合法请求域名;API Key、模型凭证和高德 Web Service Key 均不得进入客户端环境变量或构建产物。 -实时位置仅在用户点击地图中的“定位到我”后启用,并只在地图页处于前台时更新。精确坐标保留在当前内存会话中,不写入匿名偏好或路线缓存,也不会发送给可选远程 AI 服务。iOS 真机使用前,需通过真实 AppID 在微信公众平台开通定位接口并完善《用户隐私保护指引》。 +实时位置仅由用户主动触发,并只在地图页处于前台时持续更新。精确坐标保留在当前内存会话中,不写入匿名偏好或路线缓存,也不会发送给远程 AI 服务。iOS 真机使用前,需通过真实 AppID 在微信公众平台开通定位接口并完善《用户隐私保护指引》。 路由来自页面 SFC 的 ``,由 UniPages 在编译时生成 `src/pages.json`;应用清单由 `manifest.config.ts` 生成 `src/manifest.json`。不要直接修改这两个生成文件,也不要让微信开发者工具读取 `src`;IDE 只读取 `dist/dev/mp-weixin`。 diff --git a/docs/微信小程序构建与验收说明.md b/docs/微信小程序构建与验收说明.md index 752ed38..d7c1b42 100644 --- a/docs/微信小程序构建与验收说明.md +++ b/docs/微信小程序构建与验收说明.md @@ -27,7 +27,7 @@ 1. 复制 `.env.example` 为 `.env.local`。 2. 将项目方确认的微信小程序 AppID 写入 `UNI_MP_WEIXIN_APPID`。 3. 真机预览或上传前,将根目录 `project.config.json` 的 `appid` 从 `touristappid` 同步为相同的真实 AppID。 -4. 远程 AI 为可选能力;留空 `VITE_TRAVEL_ASSISTANT_API_BASE_URL` 即使用本地 POC 规划器。 +4. 远程 AI 为可选能力;留空 `VITE_TRAVEL_ASSISTANT_API_BASE_URL` 即使用本地 POC 规划器。服务端必须设置 `LLM_MODE=real` 才会作为真实 AI 展示;mock 仅用于联调演示。 5. 不要在任何客户端环境变量中写入高德 Key、AI API Key 或模型凭证,也不要把 `.env.local` 提交或外发。 `project.config.json` 中已有的 AppID 属于模板现状,未经项目方确认不能视为本期正式身份。构建以 `manifest.config.ts` 读取的本地环境配置为准。 @@ -78,7 +78,7 @@ macOS 可在另一个终端执行 `corepack pnpm ide:mp-weixin` 调起已安装 5. 返回地图后恢复分类、中心、缩放和选中点位。 6. 验证空分类、数据错误、地图错误、点位失效和图片失败降级。 7. 专项检查原生地图层级、点击穿透、安全区和快速操作稳定性。 -8. 从“AI 助手”和“路线规划”进入偏好表单,生成半日及一日行程,核对点位、顺序、时间、转场提示和来源说明。 +8. 从“AI 助手”和“路线规划”进入偏好表单,分别验证当前位置快速规划和自选 2–8 个点位规划;拖动 2–10 小时时长,核对完整点位、顺序、时间、转场提示和来源说明。 9. 关闭网络后验证本地规划仍可生成;如启用远程服务,另测合法域名、超时和服务失败提示,确认客户端产物不含服务凭证。 10. 点击“定位到我”,验证首次授权、实时蓝点、拒绝后从设置恢复,以及切换 Tab 后停止前台监听。 11. 定位成功后生成路线,确认首站按当前位置优化;检查本机存储和远程请求均不包含精确坐标。 diff --git a/project.config.json b/project.config.json index db26f9d..3dff548 100644 --- a/project.config.json +++ b/project.config.json @@ -27,4 +27,4 @@ "appid": "wx946b42048d117dde", "editorSetting": {}, "condition": {} -} \ No newline at end of file +} diff --git a/server/pyproject.toml b/server/pyproject.toml index d3faf8c..1646417 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=64"] +requires = [ "setuptools>=64" ] build-backend = "setuptools.build_meta" [project] @@ -22,9 +22,8 @@ test = [ ] [tool.setuptools] -packages = ["app"] +packages = [ "app" ] [tool.pytest.ini_options] -pythonpath = ["."] -testpaths = ["tests"] - +pythonpath = [ "." ] +testpaths = [ "tests" ] diff --git a/src/domain/travel/local-planner.ts b/src/domain/travel/local-planner.ts index 043f83f..db0ced6 100644 --- a/src/domain/travel/local-planner.ts +++ b/src/domain/travel/local-planner.ts @@ -254,6 +254,9 @@ function selectQuickPois( cursor = next } + if (selected.length === 0 && remaining.length > 0) + selected.push(rankedCandidates(remaining, preferences, [], origin)[0]) + return selected } @@ -312,7 +315,9 @@ function selectRecommendedPois( : recommendedPois } - const remaining = [...recommendedPois] + const remaining = origin + ? orderPoisFromOrigin(recommendedPois, origin) + : [...recommendedPois] const selected: PoiResolved[] = [] let usedMinutes = 0 let cursor: PlanningOrigin | PoiResolved | null = origin @@ -326,6 +331,8 @@ function selectRecommendedPois( usedMinutes += transfer + visit cursor = next } + if (selected.length === 0 && recommendedPois.length > 0) + selected.push(remaining[0] ?? recommendedPois[0]) return selected } diff --git a/src/manifest.json b/src/manifest.json index 893b2f2..9d7a7ea 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -61,7 +61,7 @@ "startLocationUpdate", "onLocationChange" ], - "projectname": "【dev】光明文旅地图" + "projectname": "【prod】光明文旅地图" }, "mp-alipay": { "usingComponents": true diff --git a/src/pages.json b/src/pages.json index 9bc1ee6..f755e1f 100644 --- a/src/pages.json +++ b/src/pages.json @@ -55,7 +55,7 @@ "type": "page", "layout": "map", "style": { - "navigationBarTitleText": "AI 路线规划", + "navigationBarTitleText": "智能路线规划", "navigationBarBackgroundColor": "#ffffff", "navigationBarTextStyle": "black", "backgroundColor": "#f4f8f6" diff --git a/src/pages/itinerary/index.vue b/src/pages/itinerary/index.vue index 7f1de34..ee7b583 100644 --- a/src/pages/itinerary/index.vue +++ b/src/pages/itinerary/index.vue @@ -110,6 +110,13 @@ const routeTags = computed(() => { }) const planningModeLabel = computed(() => plan.value?.itinerary.planningMode === 'custom' ? '自选规划' : '快速规划') +const planningSourceLabel = computed(() => { + if (plan.value?.source === 'remote_ai') + return '真实 AI 推荐' + if (plan.value?.source === 'remote_mock') + return '远端演示规划' + return '本地智能规划' +}) const durationFitNotice = computed(() => plan.value ? getDurationFitNotice(plan.value.itinerary.durationFitStatus) : null) function loadStoredPlan() { @@ -294,7 +301,7 @@ onReady(initializeMap) - {{ planningModeLabel }} + {{ planningModeLabel }} · {{ planningSourceLabel }} diff --git a/src/pages/planner/index.vue b/src/pages/planner/index.vue index 9b1599f..25b1099 100644 --- a/src/pages/planner/index.vue +++ b/src/pages/planner/index.vue @@ -2,7 +2,7 @@ import type { PoiSummary } from '@/domain/poi' import type { BudgetLevel, Interest, Pace, PlanningRequest, Theme, Transport, TravelPreferences } from '@/domain/travel' import { getPoiRepository } from '@/data/poi' -import { createPlan, isRemoteTravelAssistantEnabled, loadPreferences, savePlan, savePreferences } from '@/services/travel-assistant' +import { createPlan, isRemoteTravelAssistantEnabled, loadPlanningRequest, loadPreferences, savePlan, savePreferences } from '@/services/travel-assistant' import { useLocationStore } from '@/stores' interface PlannerPreset { @@ -74,8 +74,8 @@ const durationText = computed(() => { }) const usesCurrentLocation = computed(() => locationAvailable.value && (planningMode.value === 'quick' || customStartsFromCurrentLocation.value)) -const planningEngineLabel = computed(() => remoteEnabled.value && planningMode.value === 'quick' - ? '在线 AI 服务' +const planningEngineLabel = computed(() => remoteEnabled.value + ? '在线智能服务' : '本地智能规划') const locationActionText = computed(() => { if (locationStore.status === 'locating') @@ -151,9 +151,6 @@ function toggleInterest(interest: Interest) { function setPlanningMode(mode: PlanningRequest['mode']) { planningMode.value = mode - if (mode === 'custom' && remoteEnabled.value) { - uni.showToast({ title: '自选点位将使用本地智能规划', icon: 'none' }) - } } function togglePoi(poiId: string) { @@ -333,10 +330,18 @@ async function submit() { } onShow(() => { - const stored = loadPreferences() - if (stored) { - assignPreferences(stored) - durationMinutes.value = stored.duration === 'half_day' ? 4 * 60 : 8 * 60 + const storedRequest = loadPlanningRequest() + const storedPreferences = storedRequest?.preferences ?? loadPreferences() + if (storedPreferences) + assignPreferences(storedPreferences) + if (storedRequest) { + planningMode.value = storedRequest.mode + durationMinutes.value = storedRequest.durationMinutes + selectedPoiIds.value = [...storedRequest.selectedPoiIds] + customStartsFromCurrentLocation.value = false + } + else if (storedPreferences) { + durationMinutes.value = storedPreferences.duration === 'half_day' ? 4 * 60 : 8 * 60 } applyPreset() }) @@ -390,7 +395,7 @@ onShow(() => { - 自选点位不依赖在线服务,将使用本地智能规划。 + 在线 AI 将在你选择的点位内建议顺序,本机负责定位、交通与时长核算。 diff --git a/src/services/travel-assistant/index.ts b/src/services/travel-assistant/index.ts index fbb0529..3f79d17 100644 --- a/src/services/travel-assistant/index.ts +++ b/src/services/travel-assistant/index.ts @@ -2,10 +2,13 @@ export { isRemoteTravelAssistantEnabled } from './remote' export { adjustPlan, createPlan } from './service' export { loadPlan, + loadPlanningRequest, loadPreferences, PLAN_STORAGE_KEY, + PLANNING_REQUEST_STORAGE_KEY, PREFERENCES_STORAGE_KEY, savePlan, + savePlanningRequest, savePreferences, } from './storage' diff --git a/src/services/travel-assistant/service.ts b/src/services/travel-assistant/service.ts index 1136bcb..d246c55 100644 --- a/src/services/travel-assistant/service.ts +++ b/src/services/travel-assistant/service.ts @@ -5,7 +5,6 @@ import { cloneTravelPreferences, createLocalPlan, createRecommendedPlan, - normalizePlanResponse, normalizePlanningRequest, normalizeTravelPreferences, type PlanningOrigin, @@ -15,7 +14,7 @@ import { TravelValidationError, } from '@/domain/travel' import { createRemoteRecommendations, isRemoteTravelAssistantEnabled } from './remote' -import { loadPlan, loadPreferences, savePreferences } from './storage' +import { loadPlan, loadPlanningRequest, loadPreferences, savePlanningRequest, savePreferences } from './storage' interface LocalSession { plan: PlanResponse @@ -55,7 +54,8 @@ function restoreLocalSession(conversationId: string): LocalSession | null { const preferences = loadPreferences() if (!plan || !preferences || plan.conversationId !== conversationId) return null - rememberLocalSession(plan, { + const storedRequest = loadPlanningRequest() + rememberLocalSession(plan, storedRequest ?? { mode: plan.itinerary.planningMode, preferences, durationMinutes: plan.itinerary.requestedMinutes, @@ -118,11 +118,13 @@ export async function createPlan( remote.generationMode === 'real' ? 'remote_ai' : 'remote_mock', ) rememberLocalSession(plan, request) + savePlanningRequest(request) return clonePlanResponse(plan, repository) } const plan = createLocalPlan(request, repository) rememberLocalSession(plan, request) + savePlanningRequest(request) return clonePlanResponse(plan, repository) } @@ -153,6 +155,7 @@ export async function adjustPlan(conversationId: string, message: string): Promi adjusted.plan.source = session.plan.source } rememberLocalSession(adjusted.plan, adjusted.request) + savePlanningRequest(adjusted.request) savePreferences(adjusted.preferences) return clonePlanResponse(adjusted.plan, repository) } diff --git a/src/services/travel-assistant/storage.ts b/src/services/travel-assistant/storage.ts index ad11e52..581fd37 100644 --- a/src/services/travel-assistant/storage.ts +++ b/src/services/travel-assistant/storage.ts @@ -4,12 +4,14 @@ import { cloneTravelPreferences, normalizePlanResponse, normalizeTravelPreferences, + type PlanningRequest, type PlanResponse, type TravelPreferences, } from '@/domain/travel' export const PLAN_STORAGE_KEY = 'guangming:last-plan' export const PREFERENCES_STORAGE_KEY = 'guangming:last-preferences' +export const PLANNING_REQUEST_STORAGE_KEY = 'guangming:last-planning-request' const STORAGE_SCHEMA_VERSION = 1 @@ -99,3 +101,68 @@ export function loadPreferences(): TravelPreferences | null { return null } } + +export function savePlanningRequest(request: PlanningRequest): void { + const repository = getPoiRepository() + const sanitized: PlanningRequest = { + mode: request.mode, + preferences: normalizeTravelPreferences(request.preferences), + durationMinutes: request.durationMinutes, + selectedPoiIds: [...request.selectedPoiIds], + } + if (sanitized.mode !== 'quick' && sanitized.mode !== 'custom') + throw new Error('无法保存规划条件:规划模式无效') + if (!Number.isInteger(sanitized.durationMinutes) + || sanitized.durationMinutes < 120 + || sanitized.durationMinutes > 600 + || sanitized.durationMinutes % 30 !== 0) { + throw new Error('无法保存规划条件:规划时长无效') + } + if (sanitized.mode === 'quick' && sanitized.selectedPoiIds.length > 0) + throw new Error('无法保存规划条件:快速规划不能预选点位') + if (sanitized.mode === 'custom' + && (sanitized.selectedPoiIds.length < 2 || sanitized.selectedPoiIds.length > 8)) { + throw new Error('无法保存规划条件:自选点位数量无效') + } + if (new Set(sanitized.selectedPoiIds).size !== sanitized.selectedPoiIds.length + || sanitized.selectedPoiIds.some(poiId => !repository.getPoiById(poiId))) { + throw new Error('无法保存规划条件:自选点位无效') + } + writeStorage(PLANNING_REQUEST_STORAGE_KEY, createEnvelope(sanitized)) +} + +export function loadPlanningRequest(): PlanningRequest | null { + const repository = getPoiRepository() + const envelope = readEnvelope(readStorage(PLANNING_REQUEST_STORAGE_KEY)) + if (!envelope || envelope.datasetVersion !== repository.getDatasetMeta().datasetVersion || !isRecord(envelope.payload)) + return null + try { + const payload = envelope.payload + const request: PlanningRequest = { + mode: payload.mode as PlanningRequest['mode'], + preferences: normalizeTravelPreferences(payload.preferences), + durationMinutes: Number(payload.durationMinutes), + selectedPoiIds: Array.isArray(payload.selectedPoiIds) ? payload.selectedPoiIds.map(String) : [], + } + if (request.mode !== 'quick' && request.mode !== 'custom') + return null + if (!Number.isInteger(request.durationMinutes) + || request.durationMinutes < 120 + || request.durationMinutes > 600 + || request.durationMinutes % 30 !== 0) { + return null + } + if (request.mode === 'quick' && request.selectedPoiIds.length > 0) + return null + if (request.mode === 'custom' && (request.selectedPoiIds.length < 2 || request.selectedPoiIds.length > 8)) + return null + if (new Set(request.selectedPoiIds).size !== request.selectedPoiIds.length + || request.selectedPoiIds.some(poiId => !repository.getPoiById(poiId))) { + return null + } + return request + } + catch { + return null + } +} diff --git a/test/travel-assistant.test.ts b/test/travel-assistant.test.ts index 477b755..592d315 100644 --- a/test/travel-assistant.test.ts +++ b/test/travel-assistant.test.ts @@ -4,6 +4,7 @@ import { adjustLocalPlan, classifyDurationFit, createLocalPlan, + createRecommendedPlan, hasSignificantDurationGap, LEGACY_PLACE_ID_TO_POI_ID, normalizePlanningRequest, @@ -17,10 +18,13 @@ import { import { createPlan, loadPlan, + loadPlanningRequest, loadPreferences, PLAN_STORAGE_KEY, + PLANNING_REQUEST_STORAGE_KEY, PREFERENCES_STORAGE_KEY, savePlan, + savePlanningRequest, savePreferences, } from '../src/services/travel-assistant' @@ -354,6 +358,56 @@ describe('deterministic local POC planner', () => { }) }) +describe('reviewed AI recommendations', () => { + const repository = getPoiRepository() + const selectedPoiIds = repository.getPublishedPois().slice(0, 3).map(poi => poi.id) + const request: PlanningRequest = { + mode: 'custom', + preferences: preferences(), + durationMinutes: 120, + selectedPoiIds, + origin: { longitude: 113.94, latitude: 22.76, coordinateSystem: 'GCJ02' }, + } + const recommendations = selectedPoiIds.map((poiId, index) => ({ + poiId, + reason: `AI 白名单推荐理由 ${index + 1}`, + })) + + it('keeps all custom POIs, AI reasons and local-only location data', () => { + const plan = createRecommendedPlan(request, recommendations, repository, '真实模型推荐完成', 'remote_ai') + + expect(plan.source).toBe('remote_ai') + expect(new Set(plan.itinerary.items.map(item => item.placeId))).toEqual(new Set(selectedPoiIds)) + expect(plan.itinerary.items.every(item => item.reason.includes('AI 白名单推荐理由'))).toBe(true) + expect(plan.itinerary.durationFitStatus).toBe('overflow') + expect(JSON.stringify(plan)).not.toContain('113.94') + expect(JSON.stringify(plan)).not.toContain('22.76') + }) + + it('uses current location to optimize a quick AI recommendation and still returns an overflow route', () => { + const quickRequest: PlanningRequest = { + ...request, + mode: 'quick', + selectedPoiIds: [], + durationMinutes: 120, + origin: { longitude: 0, latitude: 0, coordinateSystem: 'GCJ02' }, + } + const plan = createRecommendedPlan(quickRequest, recommendations, repository, '推荐完成', 'remote_ai') + + expect(plan.itinerary.items).toHaveLength(1) + expect(plan.itinerary.durationFitStatus).toBe('overflow') + expect(plan.itinerary.startsFromCurrentLocation).toBe(true) + }) + + it('labels mock recommendations honestly and rejects an incomplete custom set', () => { + const mockPlan = createRecommendedPlan(request, recommendations, repository, '演示推荐完成', 'remote_mock') + expect(mockPlan.source).toBe('remote_mock') + expect(mockPlan.itinerary.title).not.toContain('AI') + expect(() => createRecommendedPlan(request, recommendations.slice(0, 2), repository, '', 'remote_ai')) + .toThrow('未完整保留') + }) +}) + describe('travel storage', () => { const values = new Map() @@ -382,6 +436,24 @@ describe('travel storage', () => { expect(values.has(PLAN_STORAGE_KEY)).toBe(true) }) + it('round-trips custom planning inputs without persisting an exact origin', () => { + const selectedPoiIds = getPoiRepository().getPublishedPois().slice(0, 4).map(poi => poi.id) + const request: PlanningRequest = { + mode: 'custom', + preferences: preferences(), + durationMinutes: 330, + selectedPoiIds, + origin: { longitude: 113.94, latitude: 22.76, coordinateSystem: 'GCJ02' }, + } + savePlanningRequest(request) + + const loaded = loadPlanningRequest()! + expect(loaded).toEqual({ ...request, origin: undefined }) + expect(loaded).not.toHaveProperty('origin') + expect(JSON.stringify(values.get(PLANNING_REQUEST_STORAGE_KEY))).not.toContain('113.94') + expect(JSON.stringify(values.get(PLANNING_REQUEST_STORAGE_KEY))).not.toContain('22.76') + }) + it('never persists planning-origin coordinates with a plan', () => { const sourcePlan = createLocalPlan( preferences(),