forked from zhouruizhe/gmTouringMiniApp
Merge pull request 'feat(travel): 接入远端 AI 推荐服务,支持 GLM real 模式与本地静默回退' (#17) from zhouruizhe/gmTouringMiniApp:zhouruizhe into main
Reviewed-on: #17
This commit was merged in pull request #17.
This commit is contained in:
@@ -1 +1,8 @@
|
||||
# 在开发时进行加载
|
||||
|
||||
# Deployed travel recommender (FastAPI, real GLM) on the gml box, exposed via
|
||||
# Tailscale Funnel. The /travel/ prefix is stripped by Tailscale before reaching
|
||||
# the app, so the client calls .../travel/api/v1/plans. Set to empty (or stop the
|
||||
# service) to use the deterministic local planner; on any remote error the client
|
||||
# silently falls back to local so the demo never breaks.
|
||||
VITE_TRAVEL_ASSISTANT_API_BASE_URL=https://desktop-gml.tailc2083d.ts.net/travel
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
LLM_MODE=mock
|
||||
|
||||
# Configure these on the server only when LLM_MODE=real.
|
||||
# Any OpenAI-compatible endpoint works, e.g. Zhipu GLM:
|
||||
# OPENAI_BASE_URL=https://open.bigmodel.cn/api/paas/v4
|
||||
# OPENAI_MODEL=glm-4-flash # free tier; use glm-4-plus etc. if you have credit
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
OPENAI_MODEL=
|
||||
|
||||
+14
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
This FastAPI service is the server-only AI boundary for the Guangming travel POC. It defaults to deterministic `mock` mode. Model credentials belong only in `server/.env` or the deployment environment and must never be added to the mini-program build.
|
||||
|
||||
This service is reserved for a later AI integration phase. The current mini-program `createPlan` entry point is hard-wired to the local planner and does not call this service.
|
||||
The mini-program calls this service when `VITE_TRAVEL_ASSISTANT_API_BASE_URL` is set (`.env.development` points it at `http://localhost:8000`). If that variable is empty, or the service is unreachable or returns an error, the client silently falls back to its deterministic local planner — so the demo always works. The model only ever recommends POIs from a coordinate-free client whitelist; route geometry, transfer estimates, and final itinerary time remain client responsibilities. The itinerary page labels the result `真实 AI 推荐` (real), `远端演示规划` (mock), or `本地 POC 规划` (local fallback).
|
||||
|
||||
## Contract
|
||||
|
||||
@@ -60,3 +60,16 @@ Run tests from the repository root:
|
||||
```bash
|
||||
corepack pnpm server:test
|
||||
```
|
||||
|
||||
## Real model mode
|
||||
|
||||
Mock mode is deterministic and needs no credentials. To use a real model, set `LLM_MODE=real` in `server/.env` (gitignored) with any OpenAI-compatible endpoint — for example Zhipu GLM:
|
||||
|
||||
```
|
||||
LLM_MODE=real
|
||||
OPENAI_BASE_URL=https://open.bigmodel.cn/api/paas/v4
|
||||
OPENAI_MODEL=glm-4-flash
|
||||
OPENAI_API_KEY=<your key>
|
||||
```
|
||||
|
||||
`GET /api/v1/health` then reports `mode=real, configured=true, ready=true`. With `pnpm server:dev` running, generate a plan from the mini-program (H5 dev server on `:5173` is already in `CORS_ORIGINS`; for `mp-weixin` enable “不校验合法域名” in the devtools). To force the local planner again, unset `VITE_TRAVEL_ASSISTANT_API_BASE_URL` or stop the server.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { PoiResolved } from '@/domain/poi'
|
||||
import TravelPoiCard from '@/components/travel/TravelPoiCard.vue'
|
||||
import { getPoiRepository } from '@/data/poi'
|
||||
import { loadPlan } from '@/services/travel-assistant'
|
||||
import { isRemoteTravelAssistantEnabled, loadPlan } from '@/services/travel-assistant'
|
||||
import { useMapStore } from '@/stores'
|
||||
|
||||
interface AssistantPrompt {
|
||||
@@ -16,6 +16,7 @@ interface AssistantPrompt {
|
||||
const mapStore = useMapStore()
|
||||
const featuredPois = ref<PoiResolved[]>([])
|
||||
const hasSavedPlan = ref(false)
|
||||
const remoteAssistantEnabled = isRemoteTravelAssistantEnabled()
|
||||
|
||||
const prompts: AssistantPrompt[] = [
|
||||
{
|
||||
@@ -166,7 +167,7 @@ onShow(loadAssistantHome)
|
||||
|
||||
<view class="assistant-notice">
|
||||
<text class="assistant-notice__title">POC 能力边界</text>
|
||||
<text>本期不调用 AI,助手使用本地 POI 和确定性规则生成路线;当前路线为推荐游览顺序,不提供实时路况或逐段导航。</text>
|
||||
<text>{{ remoteAssistantEnabled ? '已接入远端 AI:仅把无坐标的点位摘要发给模型选点,路线、交通与时长仍由本机核算;当前路线为推荐游览顺序,不提供实时路况或逐段导航。' : '本期不调用 AI,助手使用本地 POI 和确定性规则生成路线;当前路线为推荐游览顺序,不提供实时路况或逐段导航。' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { BudgetLevel, Interest, Pace, PlanningRequest, Theme, Transport, Tr
|
||||
import { getPoiRepository } from '@/data/poi'
|
||||
import { TravelValidationError } from '@/domain/travel'
|
||||
import { isLocationSnapshotFresh } from '@/services/location'
|
||||
import { createPlan, loadPlanningRequest, loadPreferences, savePlan, savePreferences } from '@/services/travel-assistant'
|
||||
import { createPlan, isRemoteTravelAssistantEnabled, loadPlanningRequest, loadPreferences, savePlan, savePreferences } from '@/services/travel-assistant'
|
||||
import { useLocationStore } from '@/stores'
|
||||
|
||||
interface PlannerPreset {
|
||||
@@ -88,7 +88,8 @@ const durationText = computed(() => {
|
||||
})
|
||||
const usesCurrentLocation = computed(() => locationAvailable.value
|
||||
&& (planningMode.value === 'quick' || customStartsFromCurrentLocation.value))
|
||||
const planningEngineLabel = '本地 POC 规划'
|
||||
const remoteAssistantEnabled = isRemoteTravelAssistantEnabled()
|
||||
const planningEngineLabel = remoteAssistantEnabled ? 'AI 辅助选点(远端)' : '本地 POC 规划'
|
||||
const locationActionText = computed(() => {
|
||||
if (locationStore.status === 'locating')
|
||||
return '定位中…'
|
||||
@@ -466,7 +467,7 @@ onUnload(deactivatePlannerPage)
|
||||
<text class="planning-mode-card__copy">从全部 POI 中选 2-8 个编排</text>
|
||||
</button>
|
||||
</view>
|
||||
<text class="planning-mode-tip">本期由本机完成选点、顺序、交通估算与时长核算。</text>
|
||||
<text class="planning-mode-tip">{{ remoteAssistantEnabled ? 'AI 从审核点位白名单中选点;顺序、交通估算与时长核算由本机完成。' : '本期由本机完成选点、顺序、交通估算与时长核算。' }}</text>
|
||||
</view>
|
||||
|
||||
<view class="planning-origin" :class="{ 'planning-origin--ready': locationAvailable }">
|
||||
@@ -695,7 +696,7 @@ onUnload(deactivatePlannerPage)
|
||||
</view>
|
||||
|
||||
<text class="planner-footer">
|
||||
匿名使用 · 本期不调用 AI · 路线与偏好仅保存在本机
|
||||
{{ remoteAssistantEnabled ? '匿名使用 · AI 仅收到无坐标的点位摘要 · 路线与偏好仅存本机' : '匿名使用 · 本期不调用 AI · 路线与偏好仅保存在本机' }}
|
||||
</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export { isRemoteTravelAssistantEnabled } from './remote'
|
||||
export type { RemotePoiCandidateInput, RemoteRecommendationResponse } from './remote'
|
||||
export { adjustPlan, createPlan, getSessionPlanningOrigin } from './service'
|
||||
export {
|
||||
loadPlan,
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import type { PoiRepository } from '@/domain/poi'
|
||||
import { getPoiRepository } from '@/data/poi'
|
||||
import {
|
||||
adjustLocalPlan,
|
||||
clonePlanResponse,
|
||||
cloneTravelPreferences,
|
||||
createLocalPlan,
|
||||
createRecommendedPlan,
|
||||
normalizePlanningRequest,
|
||||
normalizeTravelPreferences,
|
||||
type PlanningOrigin,
|
||||
type PlanningRecommendation,
|
||||
type PlanningRequest,
|
||||
type PlanResponse,
|
||||
type TravelPreferences,
|
||||
TravelValidationError,
|
||||
} from '@/domain/travel'
|
||||
import {
|
||||
createRemoteRecommendations,
|
||||
isRemoteTravelAssistantEnabled,
|
||||
type RemotePoiCandidateInput,
|
||||
} from './remote'
|
||||
import { loadPlan, loadPlanningRequest, loadPreferences, savePlanningRequest, savePreferences } from './storage'
|
||||
|
||||
interface LocalSession {
|
||||
@@ -82,6 +90,38 @@ function isPlanningRequest(input: TravelPreferences | PlanningRequest): input is
|
||||
return typeof input === 'object' && input !== null && 'mode' in input && 'preferences' in input
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the coordinate-free POI candidate whitelist sent to the remote service.
|
||||
* Quick mode offers every published POI; custom mode offers only the user's picks.
|
||||
* No coordinates ever leave the client.
|
||||
*/
|
||||
function buildRemoteCandidates(repository: PoiRepository, request: PlanningRequest): RemotePoiCandidateInput[] {
|
||||
const pois = request.mode === 'custom'
|
||||
? request.selectedPoiIds.flatMap(poiId => repository.getPoiById(poiId) ?? [])
|
||||
: repository.getPublishedPois()
|
||||
return pois.map(poi => ({
|
||||
id: poi.id,
|
||||
name: poi.name,
|
||||
categoryCode: poi.categoryCode,
|
||||
tagCodes: poi.tagCodes,
|
||||
summary: poi.summary,
|
||||
recommendationIndex: poi.recommendationIndex,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Asks the remote service for POI recommendations, then schedules them locally. */
|
||||
async function createRemotePlan(
|
||||
request: PlanningRequest,
|
||||
repository: PoiRepository,
|
||||
): Promise<PlanResponse> {
|
||||
const candidates = buildRemoteCandidates(repository, request)
|
||||
const remote = await createRemoteRecommendations(request, candidates)
|
||||
// Array order is the suggested visit order; reasons travel with each POI.
|
||||
const recommendations: PlanningRecommendation[] = remote.recommendations.map(({ poiId, reason }) => ({ poiId, reason }))
|
||||
const source = remote.generationMode === 'real' ? 'remote_ai' : 'remote_mock'
|
||||
return createRecommendedPlan(request, recommendations, repository, remote.assistantMessage, source)
|
||||
}
|
||||
|
||||
export function createPlan(input: PlanningRequest): Promise<PlanResponse>
|
||||
export function createPlan(input: TravelPreferences, planningOrigin?: PlanningOrigin): Promise<PlanResponse>
|
||||
export async function createPlan(
|
||||
@@ -98,7 +138,23 @@ export async function createPlan(
|
||||
selectedPoiIds: [],
|
||||
...(normalizePlanningOrigin(planningOrigin) ? { origin: normalizePlanningOrigin(planningOrigin)! } : {}),
|
||||
}, repository)
|
||||
|
||||
if (isRemoteTravelAssistantEnabled()) {
|
||||
try {
|
||||
const plan = await createRemotePlan(request, repository)
|
||||
rememberLocalSession(plan, request)
|
||||
savePlanningRequest(request)
|
||||
return clonePlanResponse(plan, repository)
|
||||
}
|
||||
catch (error) {
|
||||
// Resilient POC default: never block the demo on a remote hiccup.
|
||||
console.warn('Remote travel assistant unavailable, falling back to local planner', error)
|
||||
}
|
||||
}
|
||||
|
||||
const plan = createLocalPlan(request, repository)
|
||||
if (isRemoteTravelAssistantEnabled())
|
||||
plan.assistantMessage = '远程行程服务暂不可用,已改用本机确定性规划生成路线。'
|
||||
rememberLocalSession(plan, request)
|
||||
savePlanningRequest(request)
|
||||
return clonePlanResponse(plan, repository)
|
||||
|
||||
@@ -78,8 +78,9 @@ export function loadPlan(): PlanResponse | null {
|
||||
if (!envelope || envelope.datasetVersion !== repository.getDatasetMeta().datasetVersion)
|
||||
return null
|
||||
try {
|
||||
const plan = normalizePlanResponse(envelope.payload, repository)
|
||||
return plan.source === 'local_poc' ? plan : null
|
||||
// All three sources (local_poc / remote_mock / remote_ai) are safe to restore:
|
||||
// plans never carry coordinates, and normalizePlanResponse re-validates every field.
|
||||
return normalizePlanResponse(envelope.payload, repository)
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getPoiRepository } from '../src/data/poi'
|
||||
import {
|
||||
adjustLocalPlan,
|
||||
@@ -550,12 +550,14 @@ describe('travel storage', () => {
|
||||
expect(loadPlan()).toBeNull()
|
||||
})
|
||||
|
||||
it('does not restore remote plans while AI planning is frozen', () => {
|
||||
it('restores remote plans so the itinerary survives navigation', () => {
|
||||
const plan = createLocalPlan(preferences(), getPoiRepository())
|
||||
const remotePlan = { ...plan, source: 'remote_ai' as const }
|
||||
values.set(PLAN_STORAGE_KEY, storageEnvelope(remotePlan))
|
||||
|
||||
expect(loadPlan()).toBeNull()
|
||||
const loaded = loadPlan()
|
||||
expect(loaded).not.toBeNull()
|
||||
expect(loaded?.source).toBe('remote_ai')
|
||||
})
|
||||
|
||||
it('contains storage read failures and reports write failures', () => {
|
||||
@@ -571,3 +573,88 @@ describe('travel storage', () => {
|
||||
expect(() => savePreferences(preferences())).toThrow('无法保存本机行程数据:quota exceeded')
|
||||
})
|
||||
})
|
||||
|
||||
describe('remote travel assistant integration', () => {
|
||||
const repository = getPoiRepository()
|
||||
const publishedIds = repository.getPublishedPois().map(poi => poi.id)
|
||||
|
||||
beforeEach(() => {
|
||||
// createPlan persists the planning request; keep storage harmless and isolated
|
||||
// from the "quota exceeded" implementation left behind by the storage suite.
|
||||
vi.mocked(uni.setStorageSync).mockImplementation(() => {})
|
||||
vi.mocked(uni.getStorageSync).mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
vi.mocked(uni.request).mockReset()
|
||||
vi.mocked(uni.setStorageSync).mockReset()
|
||||
vi.mocked(uni.getStorageSync).mockReset()
|
||||
})
|
||||
|
||||
function quickRequest(origin = true): PlanningRequest {
|
||||
return {
|
||||
mode: 'quick',
|
||||
preferences: preferences(),
|
||||
durationMinutes: 240,
|
||||
selectedPoiIds: [],
|
||||
...(origin ? { origin: { longitude: 113.94, latitude: 22.76, coordinateSystem: 'GCJ02' } } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
it('routes createPlan through the remote service, maps generation mode, and leaks no coordinates', async () => {
|
||||
vi.stubEnv('VITE_TRAVEL_ASSISTANT_API_BASE_URL', 'http://localhost:8000')
|
||||
let postedBody: { candidates?: Array<Record<string, unknown>>, origin?: unknown }
|
||||
vi.mocked(uni.request).mockImplementation((options: { data?: unknown, success: (res: unknown) => void }) => {
|
||||
postedBody = options.data as typeof postedBody
|
||||
options.success({
|
||||
statusCode: 200,
|
||||
data: {
|
||||
requestId: 'req-remote-1',
|
||||
mode: 'quick',
|
||||
assistantMessage: '远端演示推荐完成',
|
||||
generationMode: 'mock',
|
||||
recommendations: publishedIds.slice(0, 3).map((poiId, index) => ({
|
||||
poiId,
|
||||
reason: `远端推荐理由 ${index + 1}`,
|
||||
order: index + 1,
|
||||
})),
|
||||
},
|
||||
})
|
||||
return {} as never
|
||||
})
|
||||
|
||||
const plan = await createPlan(quickRequest())
|
||||
|
||||
expect(plan.source).toBe('remote_mock')
|
||||
expect(plan.assistantMessage).toBe('远端演示推荐完成')
|
||||
expect(plan.itinerary.items.every(item => item.reason.includes('远端推荐理由'))).toBe(true)
|
||||
// The current location never leaves the client…
|
||||
expect(postedBody!.origin).toBeUndefined()
|
||||
expect(JSON.stringify(postedBody!)).not.toContain('113.94')
|
||||
expect(JSON.stringify(postedBody!)).not.toContain('22.76')
|
||||
// …candidates are coordinate-free and cover every published POI…
|
||||
expect(postedBody!.candidates).toHaveLength(publishedIds.length)
|
||||
const candidateKeys = new Set(postedBody!.candidates!.flatMap(candidate => Object.keys(candidate)))
|
||||
expect(candidateKeys.has('longitude')).toBe(false)
|
||||
expect(candidateKeys.has('latitude')).toBe(false)
|
||||
expect(candidateKeys.has('summary')).toBe(true)
|
||||
// …and the resulting plan carries no coordinates either.
|
||||
expect(JSON.stringify(plan)).not.toContain('113.94')
|
||||
expect(JSON.stringify(plan)).not.toContain('22.76')
|
||||
})
|
||||
|
||||
it('falls back to the local planner when the remote service is unreachable', async () => {
|
||||
vi.stubEnv('VITE_TRAVEL_ASSISTANT_API_BASE_URL', 'http://localhost:8000')
|
||||
vi.mocked(uni.request).mockImplementation((options: { fail: () => void }) => {
|
||||
options.fail()
|
||||
return {} as never
|
||||
})
|
||||
|
||||
const plan = await createPlan(quickRequest(false))
|
||||
|
||||
expect(vi.mocked(uni.request)).toHaveBeenCalled()
|
||||
expect(plan.source).toBe('local_poc')
|
||||
expect(plan.assistantMessage).toContain('已改用本机')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user