優先順位付きルールチェーンによる推薦ロジックのフローチャートと、禁止語彙テストの書類を持つ男性のイラスト。LLMを使わない決定論的で説明可能な推薦システム設計を示唆しています。

はじめに

「今日のおすすめ強度」「今日のペース」のように、ユーザー本人の行動履歴だけを見て何かを提案する機能を作るとき、多くの人がまず検討するのはLLMやスコアリングモデルの導入だと思います。

ですが、入力が本人の履歴と今日の予定くらいに限定されるシンプルな提案であれば、優先順位付きのルールチェーンを純粋関数として書くだけで十分なことがあります。今回は、そうしたルールベースの提案ロジックを「説明可能」かつ「安全な言い回し」で作るための設計パターンを整理してみます。LLMを使わない代わりに、テストとログでロジックの正しさを担保するアプローチです。

こんな人におすすめ

  • 「今日のおすすめ」的な機能をこれから作ろうとしている人
  • LLMやMLモデルを使わずに、それでもブラックボックス感のない判定ロジックにしたい人
  • なぜその提案になったのかを後からログで追跡できるようにしたい人
  • 通知文言のレビューを人力だけに頼らず、自動テストで担保したい人
  • 健康・フィットネスなど、決定的で説明可能な挙動が求められるドメインを扱っている人

優先順位付きif-elseチェーンとして設計する

ルールベースの提案ロジックは、条件を上から順に評価し、最初に一致したものだけを返す形にすると見通しが良くなります。分岐が多くても、上から読めば「どういう場合にどう判定されるか」がそのまま追えるからです。

type DailySuggestionInput = {
  today: string
  logs: ActivityLog[]
  todayPlan?: TodayPlan
  freezeDates?: string[]
}

type DailySuggestion = {
  kind: 'as_planned' | 'restart_small' | 'reduce_intensity' | 'default'
  reasonCode: string
  reasonMessage: string
}

export function buildDailySuggestion(input: DailySuggestionInput): DailySuggestion {
  const { today, logs, todayPlan } = input

  if (todayPlan?.isRestDay && !todayPlan.isUnset) {
    return buildSuggestion('as_planned', 'rest_day_planned', '今日はもともと休養日。予定どおり休もう')
  }

  const lastLogDate = getLastLogDate(logs, today)
  if (!lastLogDate) {
    return buildSuggestion('as_planned', 'no_history', 'はじめての記録。まずは予定どおりやってみよう')
  }

  const gapDays = getDayGap(lastLogDate, today)
  if (gapDays >= RESTART_GAP_DAYS) {
    return buildSuggestion('restart_small', 'long_gap', `${gapDays}日ぶりだから、まずは1回・1分だけやってみよう`)
  }

  // ...以降、連続実施日数×直近の負荷、練習量スパイクの判定へ続く
}

ポイントは、各分岐が理由コード(reasonCode)を必ず一緒に返すことです。判定結果だけでなく「どの条件に一致したか」がログに残るので、提案がおかしいと感じたときに、モデルの重みを追うのではなく該当の分岐を見に行くだけで原因にたどり着けます。

入力を「本人の過去比較」だけに絞る

このパターンのもう一つの特徴は、入力の狭さそのものが設計方針を表している点です。他ユーザーとの比較や属性による分岐を最初から関数のシグネチャに含めず、直近7日間の実績を「その前の7日間」と比較するような、自己参照的な指標だけで判定します。

function isRecentVolumeSpike(logs: ActivityLog[], today: string): boolean {
  const recent7 = sumVolume(logs, subDays(today, 7), today)
  const previous7 = sumVolume(logs, subDays(today, 14), subDays(today, 7))
  return previous7 > 0 && recent7 / previous7 >= VOLUME_SPIKE_RATIO
}

「他者と比較しない」「属性で分岐しない」といった制約は、後からコードレビューで守らせるより、そもそも関数が受け取れる情報を制限してしまう方が確実です。入力に他ユーザーのデータや属性情報が存在しなければ、うっかりそれを使った分岐を書くこと自体ができなくなります。

禁止語彙をテストで機械検査する

提案文言に医療的な断定や他者比較を連想させる言葉が紛れ込むと、意図せずユーザーを不安にさせたり傷つけたりするおそれがあります。これをレビュー担当者の目視だけに頼るのではなく、テストコードとして固定してしまう方法が有効です。

const FORBIDDEN_WORDS = ['診断', '治療', '病気', 'ケガ', '怪我', '順位', '他の子', 'みんなより']

describe('buildDailySuggestion の文言', () => {
  it.each(allScenarios)('どのシナリオでも禁止語彙を含まない: %s', (scenario) => {
    const result = buildDailySuggestion(scenario.input)

    for (const word of FORBIDDEN_WORDS) {
      expect(result.reasonMessage).not.toContain(word)
    }
  })
})

全シナリオに対して禁止語彙リストをループで検査するだけなので実装コストは低いですが、新しい分岐を追加したときに文言レビューを忘れても機械的に検知できるのは大きな安心材料になります。

境界値と入力順序をテストで担保する

ルールチェーンは分岐条件の境界(「◯日以上」「◯倍以上」など)でバグが出やすい領域です。特に日数差分の計算はオフバイワンエラーが起きやすいので、「閾値ちょうど手前では発火しない」「月をまたぐ日数差分でも正しく計算される」といったケースを個別のテストとして明示しておくと安心です。

it('閾値ちょうど手前(gapDays = RESTART_GAP_DAYS - 1)では long_gap にならない', () => {
  const result = buildDailySuggestion(scenarioWithGap(RESTART_GAP_DAYS - 1))
  expect(result.reasonCode).not.toBe('long_gap')
})

it('ログ配列の順序を反転しても同じ結果になる', () => {
  const forward = buildDailySuggestion({ ...baseInput, logs })
  const reversed = buildDailySuggestion({ ...baseInput, logs: [...logs].reverse() })
  expect(reversed).toEqual(forward)
})

入力順序に依存しないことを明示的にテストしておくと、集計・並び替えロジックの実装ミス(最新ログの取り違えなど)を早期に発見できます。

つまづきやすいポイント

  • 日数差分の計算はオフバイワンエラーが起きやすく、境界値のテストを書かないと閾値の「以上」「より大きい」の取り違えに気づきにくい
  • booleanひとつだけでは状態を表現しきれないことがある。「休養日フラグ」が立っていても「未設定」を意味する場合があるなど、一段掘り下げた判定が必要になる
  • 分岐の順序を入れ替えると、意図せず優先順位が変わってしまう。上から評価するチェーンでは、追加した条件をどこに挿入するかが仕様そのものになる
  • 古いデータには新しいフィールドが存在しないことがある。フォールバック値を用意しておかないと、レガシーデータでクラッシュする

まとめ

「今日のおすすめ」のような一見AIっぽい機能も、入力が本人の履歴に閉じているなら、優先順位付きのルールチェーンで十分に説明可能な形にできます。理由コードをログに残し、禁止語彙をテストで機械検査し、境界値と入力順序をテストで担保する。この3つを揃えるだけで、LLMを使わなくても「なぜその提案になったのか」を後から追跡できる仕組みが作れます。複雑なモデルを持ち込む前に、まずはルールチェーンで足りないかを検討してみる価値はありそうです。