Merge pull request 'Update AI planner integration, docs, and config' (#1) from zhouruizhe/gmTouringMiniApp:main into main

Reviewed-on: team/gmTouringMiniApp#1
Reviewed-by: admin <zhouruihe@gml.ac.cn>
This commit is contained in:
2026-07-30 16:40:20 +08:00
13 changed files with 192 additions and 29 deletions
+3 -3
View File
@@ -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 的 `<route>`,由 UniPages 在编译时生成 `src/pages.json`;应用清单由 `manifest.config.ts` 生成 `src/manifest.json`。不要直接修改这两个生成文件,也不要让微信开发者工具读取 `src`IDE 只读取 `dist/dev/mp-weixin`
+2 -2
View File
@@ -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. 定位成功后生成路线,确认首站按当前位置优化;检查本机存储和远程请求均不包含精确坐标。
+4 -5
View File
@@ -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" ]
+8 -1
View File
@@ -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
}
+1 -1
View File
@@ -61,7 +61,7 @@
"startLocationUpdate",
"onLocationChange"
],
"projectname": "【dev】光明文旅地图"
"projectname": "【prod】光明文旅地图"
},
"mp-alipay": {
"usingComponents": true
+1 -1
View File
@@ -55,7 +55,7 @@
"type": "page",
"layout": "map",
"style": {
"navigationBarTitleText": "AI 路线规划",
"navigationBarTitleText": "智能路线规划",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"backgroundColor": "#f4f8f6"
+8 -1
View File
@@ -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)
<view class="route-hero__top">
<view class="route-hero__label">
<text class="route-hero__spark"></text>
{{ planningModeLabel }}
{{ planningModeLabel }} · {{ planningSourceLabel }}
</view>
<button @click="openPlanner">重新规划</button>
</view>
+16 -11
View File
@@ -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(() => {
</button>
</view>
<text v-if="remoteEnabled && planningMode === 'custom'" class="planning-mode-tip">
自选点位不依赖在线服务将使用本地智能规划
在线 AI 将在你选择的点位内建议顺序本机负责定位交通与时长核算
</text>
</view>
+3
View File
@@ -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'
+6 -3
View File
@@ -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)
}
+67
View File
@@ -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
}
}
+72
View File
@@ -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<string, unknown>()
@@ -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(),