昨天做了單次散步分析,完成散步後要告訴使用者「這次有什麼值得注意的地方」。
但如果只看一次散步還是不夠。
例如使用者最近一個月只走了幾次,或者最近使用推薦路線時經常沒有走完,那下一次的建議是不是也應該跟著改變?
所以這次想做的事情,是把最近幾次散步放在一起看,根據使用者目前的散步狀況,決定下一次比較適合怎麼調整。
流程和昨天一樣,還是由後端先做判斷,再讓 AI 負責把結果說清楚:
我需要的是:
所以後端會先整理出最近 7 天和 30 天的統計資料。
例如:
可靠度的判斷則沿用昨天的規則,不可靠的紀錄不會進入統計。
其中比較重要的是 中位數。
假設最近幾次大多走 30 分鐘,但其中一次走了 90 分鐘,如果直接使用平均值,這筆比較特殊的紀錄就可能把下一次目標拉高。
所以這裡會使用 30 天可靠散步的中位數作為主要基準。
有了這些統計資料之後,下一個問題就是:
什麼情況應該給什麼建議?
這裡我沒有讓 AI 自己判斷,而是直接在後端寫成規則。
目前有四種目標:
type CoachingGoal =
| 'resume_habit'
| 'maintain_rhythm'
| 'increase_challenge'
| 'reduce_difficulty';
實際判斷會按照優先順序進行:
if (validWalkCount30d < 3) {
return 'resume_habit';
}
if (
recommendedSampleCount >= 2 &&
averageRouteCompletionRatio < 0.6
) {
return 'reduce_difficulty';
}
if (earlyStopRatio30d >= 0.4) {
return 'reduce_difficulty';
}
if (
recommendedSampleCount >= 2 &&
averageOffRouteCount >= 3
) {
return 'reduce_difficulty';
}
if (walkCount7d <= 1) {
return 'resume_habit';
}
return 'maintain_rhythm';
如果 30 天內連 3 次可靠散步都沒有,就先以重新建立散步習慣為主。
如果資料足夠,則會優先處理推薦路線完成率低、經常提早結束或頻繁偏離路線等情況。
都沒有命中時,就維持目前的散步節奏。
目前雖然保留了 increase_challenge 這個目標,但實際規則並不會因為使用者最近走得穩定,就自動增加難度。
決定方向之後,才會計算實際的目標。
例如最近散步頻率偏低,就使用歷史中位數的 90%;如果推薦路線完成率低或經常提早結束,就會再保守一點,使用 75%。
const scale =
reason === 'low_frequency'
? 0.9
: reason === 'low_completion' ||
reason === 'frequent_early_stop'
? 0.75
: 1;
const targetDuration =
roundToFiveMinutes(medianDuration * scale);
const targetDistance =
roundToHundredMeters(medianDistance * scale);
最後還會限制目標範圍,例如時間最多 60 分鐘、距離最多 10 公里,避免特殊紀錄直接產生太大的目標。
如果完全沒有可靠的歷史資料,則使用最基本的 15 分鐘和簡單路線作為起點。
這些數字全部由後端決定,AI 不會修改。
到這裡其實後端已經知道下一次要怎麼建議了,例如:
{
goal: 'reduce_difficulty',
target: {
durationMinutes: 25,
distanceMeters: 2000,
preferSimplerRecommendedRoute: true,
routeComplexity: 'simple',
}
}
這時才把整理好的 WalkCoachingContext 交給 AI。
AI 的工作就很單純,只需要把「為什麼這樣建議」以及「下一次可以怎麼做」說清楚。
例如後端決定下一次先走 25 分鐘,AI 可以整理成:
最近幾次散步比較容易提早結束,這次先把時間縮短一點,讓整段散步比較容易走完。
AI 不能自己把 25 分鐘改成 40 分鐘,也不能從資料裡自行推論體能或疲勞。
這次 AI 最後需要回傳固定的結構:
const coachingOutputSchema = z.object({
goal: z.enum([
'resume_habit',
'maintain_rhythm',
'increase_challenge',
'reduce_difficulty',
]),
title: z.string().min(1).max(80),
reason: z.string().min(1).max(300),
suggestion: z.string().min(1).max(300),
target: z.object({
durationMinutes: z.number().int().min(10).max(90).nullable(),
distanceMeters: z.number().int().min(500).max(15_000).nullable(),
preferSimplerRecommendedRoute: z.boolean(),
routeComplexity: z.enum([
'simple',
'normal',
'challenging',
]),
}),
});
這裡的 goal 和 target 雖然也包含在 AI 的輸出裡,但它們其實不是讓 AI 自己決定的。
在呼叫 AI 之前,後端就已經算好這次的 goal 和 target,Prompt 也會要求 AI 原樣保留這些內容。
因此收到結果後,後端還會再確認:
AI 回傳的 goal === 後端決定的 goal
AI 回傳的 target === 後端決定的 target
如果 AI 自己修改了內容,例如後端決定:
goal: 'reduce_difficulty'
durationMinutes: 25
AI 卻回傳:
goal: 'maintain_rhythm'
durationMinutes: 40
這份結果就會直接視為無效,改用 fallback。
這樣做雖然多了一層驗證,但可以確保 AI 最後產生的完整結果,和後端原本的決策一致。
延續昨天的做法,但這次 Cache 是以使用者為單位。
後端會把這次分析使用的 Context 序列化後計算 SHA-256:
const inputHash = await sha256(
JSON.stringify(context),
);
如果資料和 Prompt 版本都沒有改變,就直接使用之前的結果,不需要重新呼叫 AI。
如果使用者按下「換一種說法」,則會帶上 regenerate: true
這會跳過 Cache,重新產生一次。
另外如果同一時間有兩個請求一起產生分析建議,後端也會透過生成鎖避免同時產生兩份結果。
AI 並不是整個功能唯一的依賴。
如果 AI 呼叫失敗,或者回傳內容沒有通過驗證,後端會直接使用確定性的 fallback。
例如後端已經決定 goal: 'resume_habit',那 fallback 就會根據這個目標產生固定文案。
因此即使 AI 暫時無法使用,使用者還是可以拿到完整的散步建議。
而且 fallback 和 AI 的差別只有「說法」,不是「判斷結果」。