iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0
佛心分享-SideProject30

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

AI 散步分析(1)——那些統計數字,到底代表什麼?

  • 分享至 

  • xImage
  •  

到現在,散步詳情頁已經有距離、時間、速度這些數字了。

再加入 AI 時,我不想把整段 GPS、暫停紀錄、近期散步資料全部丟給 AI,因為這樣等於連「要分析什麼」都交給 AI 決定了。

即使最後產生的文字看起來合理,也很難保證它真的是使用者需要的內容。

所以這次我把分析拆成兩個階段:

  1. 後端:先分析散步資料,找出值得注意的地方
  2. AI:再把這些內容整理成使用者看得懂的文字

也就是後端決定「說什麼」,AI 負責「怎麼說」。

後端先確認資料是否可靠

使用者開始分析後,後端不會直接把整筆散步資料交給 AI,而是先整理分析需要的資料。

其中第一件事是確認這筆散步是否可靠。

例如平均速度或最高速度出現不合理的數值、移動時間太短,或距離太短,都可能讓這筆散步不適合拿來做比較。

這部分完全由程式判斷:

export const WALK_ANALYSIS_RULES = {
  unusualAverageWalkingSpeedMps: 3,
  unusualPeakWalkingSpeedMps: 6,
  minimumReliableMovingSeconds: 300,
  minimumReliableDistanceMeters: 300,
} as const;

type QualityRule = {
  issue: WalkAnalysisQuality['issues'][number];
  check: (walk: Walk) => boolean;
};

const RULES: QualityRule[] = [
  {
    issue: 'abnormal_average_speed',
    check: (w) =>
      w.averageSpeedMps >
      WALK_ANALYSIS_RULES.unusualAverageWalkingSpeedMps,
  },
  {
    issue: 'abnormal_max_speed',
    check: (w) =>
      w.maxSpeedMps >
      WALK_ANALYSIS_RULES.unusualPeakWalkingSpeedMps,
  },
  {
    issue: 'too_short',
    check: (w) =>
      w.movingTimeSeconds <
        WALK_ANALYSIS_RULES.minimumReliableMovingSeconds ||
      w.distanceMeters <
        WALK_ANALYSIS_RULES.minimumReliableDistanceMeters,
  },
];

export function evaluateWalkAnalysisQuality(
  walk: Walk,
): WalkAnalysisQuality {
  const issues = RULES
    .filter((rule) => rule.check(walk))
    .map((rule) => rule.issue);

  const isReliable =
    walk.status === 'completed' && issues.length === 0;

  return {
    isReliable,
    issues,
    includeInBaseline: isReliable,
  };
}

如果資料不可靠,這筆散步仍然可以分析,但不會拿來當作近期散步的比較基準。

近期基準也只會使用可靠的散步紀錄,最多取 10 筆;少於 3 筆時,就不會拿來做需要基準的比較。

除了資料品質,後端還會計算這次散步的速度、暫停比例、前後半段速度變化,以及和近期散步的差異。

這些數值都是後端計算出來的,不是讓 AI 自己猜。

後端先整理分析候選

有了這些資料之後,後端會進一步找出「這次散步有哪些地方值得注意」。

這些還不是最後給使用者看的文字,而是分析候選。

程式裡使用 InsightCandidate 表示一個候選重點。它會記錄這個重點的類型、重要程度、可以用來佐證的資料,以及是否值得提醒使用者。

例如可能會出現:

  • 後半段速度反覆下降
  • 暫停比例偏高
  • 推薦路線完成率偏低
  • 這次散步的速度明顯改變
  • 距離增加主要來自散步時間變長

如果資料本身不可靠,則會直接只留下資料品質相關的候選,可靠的資料則會繼續套用其他分析規則。

最後再按照重要程度排序,最多保留 4 個候選。

到這裡,後端已經完成「這次散步值得注意什麼」的判斷,但還沒有讓 AI 自己重新分析。

AI 只處理後端選出的重點

真正送給 AI 的內容也不是整份 GPS 或原始散步資料,而是後端整理好的候選。

Prompt 會直接限制 AI:

你收到的是 deterministic analysis
已挑選並排序的 Insight Candidates,
不是原始散步資料。

只能逐項解釋 candidates 內的 type 與 evidence,
不可引入其他議題或自行發現 pattern。

這裡的 Insight,指的是從散步資料中整理出來,最值得告訴使用者的一個發現。

所以 AI 拿到的其實已經是這次值得注意的是速度變化。它的工作不是重新判斷今天是不是走得比較慢?而是把前面的分析結果整理成自然、容易理解的文字。

這樣可以避免 AI 從大量資料中自行發現產品原本沒有打算分析的內容。

一次只呈現一個分析重點

後端可能找出好幾個候選,但最後只會產生一個主要 Insight。

因為如果一次把速度、暫停、散步時間、路線、GPS 品質全部告訴使用者,很容易變成一份散步報告,而不是一個容易理解的回饋。

所以 AI 最後會整理成:

{
  title,
  description,
  evidence,
  suggestion
}

例如:

  1. 後端:這次最值得說的是「速度比近期散步慢」。
  2. AI:把這件事整理成標題、說明、證據和建議。

這也讓後端和 AI 的責任非常清楚:後端負責分析和決定內容,AI 負責把內容表達出來。

Structured Output 固定 AI 的回傳格式

AI 如果直接回傳一般文字,程式很難穩定處理。

例如我們希望最後拿到:

{
  title,
  description,
  evidence,
  suggestion
}

但如果只是告訴 AI「請用 JSON 回傳」,它仍然可能漏欄位、改欄位名稱,甚至回傳其他格式。

因此這裡使用 Structured Output(結構化輸出),要求 AI 按照指定的 JSON Schema 回傳固定格式。

這次的結果需要包含 hasMeaningfulInsightinsights

const walkAnalysisOutputSchema = z.object({
  hasMeaningfulInsight: z.boolean(),
  insights: z
    .array(
      z.object({
        title: z.string().min(1).max(80),
        description: z.string().min(1).max(300),
        evidence: z.string().min(1).max(180).nullable(),
        suggestion: z.string().min(1).max(180).nullable(),
      }),
    )
    .max(1),
});

這裡的 .max(1) 就是確保最後最多只有一個分析重點。

不過固定格式不代表內容一定正確,所以收到 AI 結果後,還會再進行驗證。

除了確認資料符合 Schema,也會確認:

  • 有 Insight 時,insights 必須剛好有一筆
  • 沒有候選時,不能自行產生 Insight
  • 不能加入後端沒有提供的觀察
  • 不能推論疾病、熱量、體能、疲勞或心情等資料沒有支持的內容

也就是:

  1. Structured Output:限制 AI 回傳格式
  2. Zod:確認資料符合 Schema
  3. 分析規則:確認內容沒有超出後端分析結果

這幾層其實是在解決不同問題。

Structured Output 管的是「AI 要用什麼格式回答」。

Zod 管的是「程式收到的資料是不是符合我們定義的 Schema」。

最後的分析規則管的是「這段內容有沒有超出後端原本允許 AI 說的範圍」。

相同資料直接使用既有結果

如果使用者短時間內重複分析同一筆散步,沒有必要每次都重新呼叫 AI。

所以後端會先把這次分析使用的資料序列化,再計算 SHA-256:

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

資料庫會保存這個 inputHash

下一次分析時,如果分析資料和 Prompt 版本都沒有改變,而且使用者沒有要求重新產生,就直接使用之前成功的結果。

if (
  existing &&
  !regenerate &&
  existing.inputHash === inputHash &&
  existing.promptVersion === WALK_ANALYSIS_PROMPT_VERSION
) {
  return {
    analysis: stripHash(existing),
    cached: true,
  };
}

這裡 Hash 的不是 AI 最後產生的文字,而是分析時使用的資料

因此如果近期又多了一筆可靠散步,或其他分析資料發生變化,Hash 也會跟著改變,下一次就會重新分析。

這樣 Cache 不只是單純避免重複呼叫 AI,也能確保分析資料真的改變時,不會一直使用舊結果。

regenerate 重新產生結果

如果使用者按下「重新產生」,App 會傳入 regenerate: true

即使已經有相同資料的 Cache,regenerate 也會跳過 Cache,繼續執行後續的生成流程。

不過這不代表讓 AI 自己重新決定分析方向。

後端仍然會重新整理資料、找出分析候選,再交給 AI 產生新的文字。

所以 regenerate 做的是重新產生一次結果,而不是讓 AI 重新找一個問題。

避免同時產生兩份結果

Cache 解決的是「之前已經算過」。

但如果使用者快速按兩次分析,還是可能有兩個請求同時進來。

因此開始生成之前,後端會先確認這筆散步目前是不是已經有分析正在進行。

如果已經有另一個請求正在生成,就回傳 409,避免同一筆散步同時產生兩份結果。

這和 Cache 解決的是不同問題:

  • Cache:之前已經有結果,不需要重新生成
  • 生成鎖:現在已經有人正在生成,不要同時再生成一次

AI 失敗時使用替代結果

AI 不是整個分析流程唯一的依賴。

如果 AI 呼叫失敗、輸出的 JSON 驗證失敗,或內容違反分析規則,後端會使用當下分析資料對應的固定結果作為 fallback。

const fallback = createWalkAnalysisRequest(context).mockValue;

await this.repository.complete(
  walkId,
  token,
  fallback,
  generatedAt,
);

這裡的重點是,fallback 也會根據後端已經選出的分析結果產生

也就是 AI 失敗時,不是重新做一次分析,而是直接用程式把同一個分析結果整理成固定文案。

例如後端已經判斷「暫停比例偏高」,那 AI 失敗時,fallback 也應該描述「暫停比例偏高」,而不是突然變成「這次散步與近期差不多。」

這樣才能確保不管最後是 AI 產生文字,還是使用 fallback,分析方向都由同一套後端規則決定

如果是使用者主動重新產生時發生失敗,App 也會保留原本已經成功的結果,而不是讓畫面變成空白。

目前 AI 分析的畫面長這樣:

明天要分享的是如果只分析這一次還不夠,那能不能把最近幾次散步放在一起,進一步決定使用者下一次適合怎麼走?


上一篇
Widget(3)——資料變了之後怎麼更新 Widget
系列文
30 天開發一款真正能每天使用的散步 App26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言