iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0
佛心分享-SideProject30

30 天開發一款真正能每天使用的散步 App系列 第 27

AI 散步分析(2)——從最近的散步紀錄決定下一次的目標

  • 分享至 

  • xImage
  •  

昨天做了單次散步分析,完成散步後要告訴使用者「這次有什麼值得注意的地方」。

但如果只看一次散步還是不夠。

例如使用者最近一個月只走了幾次,或者最近使用推薦路線時經常沒有走完,那下一次的建議是不是也應該跟著改變?

所以這次想做的事情,是把最近幾次散步放在一起看,根據使用者目前的散步狀況,決定下一次比較適合怎麼調整。

流程和昨天一樣,還是由後端先做判斷,再讓 AI 負責把結果說清楚:

  1. 後端整理最近的可靠散步紀錄
  2. 後端根據統計資料決定下一次的目標
  3. AI 把這個結果整理成使用者看得懂的建議

不把每次散步直接丟給 AI

我需要的是:

  • 最近 7 天走了幾次?
  • 通常可以走多久?
  • 推薦路線完成得怎麼樣?

所以後端會先整理出最近 7 天和 30 天的統計資料。

例如:

  • 最近 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 不會修改。

AI 這次只負責把目標說清楚

到這裡其實後端已經知道下一次要怎麼建議了,例如:

{
  goal: 'reduce_difficulty',
  target: {
    durationMinutes: 25,
    distanceMeters: 2000,
    preferSimplerRecommendedRoute: true,
    routeComplexity: 'simple',
  }
}

這時才把整理好的 WalkCoachingContext 交給 AI。

AI 的工作就很單純,只需要把「為什麼這樣建議」以及「下一次可以怎麼做」說清楚。

例如後端決定下一次先走 25 分鐘,AI 可以整理成:

最近幾次散步比較容易提早結束,這次先把時間縮短一點,讓整段散步比較容易走完。

AI 不能自己把 25 分鐘改成 40 分鐘,也不能從資料裡自行推論體能或疲勞。

Structured Output 固定回傳格式

這次 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',
    ]),
  }),
});

這裡的 goaltarget 雖然也包含在 AI 的輸出裡,但它們其實不是讓 AI 自己決定的。

在呼叫 AI 之前,後端就已經算好這次的 goaltarget,Prompt 也會要求 AI 原樣保留這些內容。

因此收到結果後,後端還會再確認:

AI 回傳的 goal === 後端決定的 goal
AI 回傳的 target === 後端決定的 target

如果 AI 自己修改了內容,例如後端決定:

goal: 'reduce_difficulty'
durationMinutes: 25

AI 卻回傳:

goal: 'maintain_rhythm'
durationMinutes: 40

這份結果就會直接視為無效,改用 fallback。

這樣做雖然多了一層驗證,但可以確保 AI 最後產生的完整結果,和後端原本的決策一致。

Cache、重新產生和生成鎖

延續昨天的做法,但這次 Cache 是以使用者為單位。

後端會把這次分析使用的 Context 序列化後計算 SHA-256:

const inputHash = await sha256(
  JSON.stringify(context),
);

如果資料和 Prompt 版本都沒有改變,就直接使用之前的結果,不需要重新呼叫 AI。

如果使用者按下「換一種說法」,則會帶上 regenerate: true

這會跳過 Cache,重新產生一次。

另外如果同一時間有兩個請求一起產生分析建議,後端也會透過生成鎖避免同時產生兩份結果。

AI 失敗也不能讓建議消失

AI 並不是整個功能唯一的依賴。

如果 AI 呼叫失敗,或者回傳內容沒有通過驗證,後端會直接使用確定性的 fallback。

例如後端已經決定 goal: 'resume_habit',那 fallback 就會根據這個目標產生固定文案。

因此即使 AI 暫時無法使用,使用者還是可以拿到完整的散步建議。

而且 fallback 和 AI 的差別只有「說法」,不是「判斷結果」。


上一篇
AI 散步分析(1)——那些統計數字,到底代表什麼?
下一篇
AI 散步分析(3)——根據分析結果產生推薦路線
系列文
30 天開發一款真正能每天使用的散步 App29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言