logo

SavvyBot Phase 6
UX改善とインタラクティブTODO管理

AI / チャットボット開発フルスタック開発 / UX設計2025年10月〜11月2日2025-11-07

ユーザー名表示、期限表示改善、LINE Quick Reply Buttons統合、インタラクティブTODO管理(完了/未完了/削除/編集)を実装。メニューシステム、日本語エイリアス対応で操作性を大幅に向上。

Technologies Used

LINE Messaging APILINE Quick Reply APILINE Profile APIOpenAI Function CallingPostgreSQLTypeScript

SavvyBot Phase 6 ― UX改善とインタラクティブTODO管理

← Phase 5へ | Phase 7計画へ → | プロジェクト概要

概要

Phase 6では、ユーザー体験を大幅に改善し、インタラクティブなTODO管理機能を実装しました。LINE Quick Reply Buttonsを活用した3ステップ対話型UIにより、複雑な操作を直感的に行えるようになりました。

Phase 6完了状況(2025年11月2日時点):

  • ✅ Phase 6.1: ユーザー名表示と暗黙的自己割り当て
  • ✅ Phase 6.2: 期限表示改善とキーワード検出
  • ✅ Phase 6.3.1: インタラクティブTODO管理(テキストコマンド)
  • ✅ Phase 6.3.1 追加: メニューシステム + 日本語エイリアス + データ品質改善
  • ✅ Phase 6.3.2: LINE Quick Reply Buttons
  • ✅ Phase 6.4: TODO Done/Undone/Delete Commands
  • ✅ Phase 6.5: TODO Edit Feature

Phase 6.1: ユーザー名表示と暗黙的自己割り当て(完了 - 2025年10月28日)

達成事項

  • ✅ LINEユーザープロファイルキャッシュシステムの実装(24時間TTL)
  • ✅ TODOの暗黙的自己割り当て機能("私がやる" → 発言者に自動割り当て)
  • ✅ 全コマンドでuserIdの代わりに表示名を表示(@林田夏樹、@田中さん など)
  • ✅ 実機テストで動作確認完了

技術的実装

LINEユーザープロファイルキャッシュ

データベーススキーマ:

CREATE TABLE user_profiles (
  user_id TEXT PRIMARY KEY,
  display_name TEXT NOT NULL,
  picture_url TEXT,
  status_message TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- TTLチェック用インデックス
CREATE INDEX idx_user_profiles_updated_at ON user_profiles(updated_at);

プロフィール取得ロジック (lib/db.ts):

export async function getUserProfile(
  userId: string,
  lineAccessToken: string
): Promise<Result<UserProfile, string>> {
  // キャッシュ確認(24時間以内)
  const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
  const { data: cached } = await supabase
    .from('user_profiles')
    .select('*')
    .eq('user_id', userId)
    .gte('updated_at', twentyFourHoursAgo.toISOString())
    .single()

  if (cached) {
    return { ok: true, value: cached }
  }

  // LINE Profile API呼び出し
  const response = await fetch(
    `https://api.line.me/v2/bot/profile/${userId}`,
    { headers: { Authorization: `Bearer ${lineAccessToken}` } }
  )

  const profile = await response.json()

  // キャッシュ更新
  await supabase.from('user_profiles').upsert({
    user_id: userId,
    display_name: profile.displayName,
    picture_url: profile.pictureUrl,
    status_message: profile.statusMessage,
    updated_at: new Date().toISOString(),
  })

  return { ok: true, value: profile }
}

暗黙的自己割り当て

実装内容 (index.ts):

// OpenAIがTODOを抽出
const extractionResult = await extractInformation(messages, userId, groupId)

for (const todo of extractionResult.value.todos) {
  // assigneeが認識できない場合は発言者に割り当て
  const assignee = todo.assignee || userId

  await db.insertTodo({
    group_id: groupId,
    task: todo.task,
    assignee,  // 暗黙的自己割り当て
    deadline: todo.deadline,
  })
}

効果

修正前:

⏳ タスク1 (担当: U1234567890)
⏳ タスク2 (担当: U9876543210)

修正後:

⏳ タスク1 (担当: @林田夏樹)
⏳ タスク2 (担当: @田中さん)

Phase 6.2: 期限表示改善とキーワード検出(完了 - 2025年10月28日)

達成事項

  • ✅ 期限なしTODOに「期日未定」と明示表示
  • ✅ 緊急度キーワードの自動認識(「今日」「急ぎ」「至急」→今日の日付を自動設定)
  • ✅ OpenAIへの現在日時コンテキスト提供(相対表現の正確な解釈)
  • ✅ デプロイ完了

実装内容

期限表示ロジック (lib/commands.ts):

let deadline = ''
if (t.deadline) {
  const jstDate = new Date(t.deadline)
  jstDate.setHours(jstDate.getHours() + 9) // UTC → JST
  deadline = ` (期限: ${jstDate.toLocaleDateString('ja-JP')})`
} else {
  deadline = ' (期日未定)'
}

現在日時コンテキスト (lib/openai.ts):

const currentDate = new Date().toLocaleString('ja-JP', {
  timeZone: 'Asia/Tokyo',
  year: 'numeric',
  month: 'long',
  day: 'numeric',
  weekday: 'long',
  hour: '2-digit',
  minute: '2-digit',
})

const systemPrompt = `あなたはSavvyBotです。
現在の日時: ${currentDate}

TODOの期限を抽出する際のルール:
1. 明示的な日付(「10月30日まで」)→ その日付の23:59
2. 緊急度キーワード(「今日」「急ぎ」「至急」)→ 今日の23:59
3. 相対表現(「明日」「今週」「来週」)→ 適切に計算
4. 期限の言及なし → null`

効果

修正前:

⏳ パンを買う

修正後:

⏳ パンを買う (期日未定)
⏳ 今日牛乳を買う (期限: 10/28)

Phase 6.3: インタラクティブTODO管理

Phase 6.3.1: テキストコマンド + メニューシステム(完了 - 2025年10月28日)

新しいコマンド

TODO操作コマンド:

  • /sv:done <番号> - TODO完了
  • /sv:undone <番号> - TODO未完了に戻す
  • /sv:delete todo <番号> - TODO削除
  • /sv:edit todo <番号> [オプション] - TODO編集

メニューシステム:

  • /sv:menu または /sv:メニュー - コマンドメニュー表示
  • /sv:1/sv:9 - 番号で直接実行
    • /sv:1 - 議事録生成
    • /sv:2 - 決定事項確認
    • /sv:3 - タスク一覧
    • /sv:4 - 質問リスト
    • /sv:5 - 会話検索
    • /sv:6 - タスク完了
    • /sv:7 - タスク削除
    • /sv:8 - タスク編集
    • /sv:9 - ヘルプ表示

日本語エイリアス:

  • /sv:議事録, /sv:決定, /sv:タスク, /sv:質問, /sv:検索
  • /sv:完了, /sv:削除, /sv:編集, /sv:ヘルプ

データ品質改善

非実行可能TODO のフィルタリング (lib/openai.ts):

function isValidTodo(task: string): boolean {
  const nonActionablePatterns = [
    /特に.*ない/,
    /今のところ.*ない/,
    /特になし/,
    /特に決まっていない/,
  ]

  for (const pattern of nonActionablePatterns) {
    if (pattern.test(task)) {
      logger.debug({
        event_type: 'extraction.todo.filtered',
        task,
        reason: 'non_actionable',
      })
      return false
    }
  }

  return true
}

Phase 6.3.2: LINE Quick Reply Buttons(完了 - 2025年10月28日)

達成事項

  • ✅ LINE Messaging API Quick Reply統合
  • ✅ メニューコマンドに6つのQuick Replyボタン追加
  • ✅ TODOリストに表示モード切り替えボタン追加
  • ✅ 型安全なCommandResponse実装(Union Type)
  • ✅ デプロイ完了(実機テスト済み)

ユーザーフィードバック: 「最高です!」

技術的実装

LINE Quick Reply型定義 (lib/line.ts):

export interface QuickReplyButton {
  label: string  // ボタンラベル(最大20文字)
  text: string   // タップ時に送信されるテキスト
}

interface LineQuickReply {
  items: Array<{
    type: 'action'
    action: {
      type: 'message'
      label: string
      text: string
    }
  }>
}

CommandResponse Union Type:

export type CommandResponse =
  | string
  | {
      text: string
      quickReply?: QuickReplyButton[]
    }

replyToLine()拡張 (lib/line.ts):

export async function replyToLine(
  replyToken: string,
  text: string,
  config: Config,
  logger: Logger,
  requestId: string,
  isError: boolean = false,
  quickReply?: QuickReplyButton[]
): Promise<Result<void, string>> {
  const message: any = {
    type: 'text',
    text,
  }

  if (quickReply && quickReply.length > 0) {
    message.quickReply = {
      items: quickReply.slice(0, 13).map(btn => ({
        type: 'action',
        action: {
          type: 'message',
          label: btn.label,
          text: btn.text,
        },
      })),
    }
  }

  const response = await fetch('https://api.line.me/v2/bot/message/reply', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${config.lineChannelAccessToken}`,
    },
    body: JSON.stringify({
      replyToken,
      messages: [message],
    }),
  })

  return { ok: true, value: undefined }
}

UI例

メニューコマンド(/sv:menu):

📋 【コマンドメニュー】

以下の番号でコマンドを実行できます:

1️⃣ /sv:1 - 議事録を生成
2️⃣ /sv:2 - 決定事項を確認
3️⃣ /sv:3 - タスク一覧(期限別)
...

[📝 議事録] [✅ 決定事項] [📋 タスク] [❓ 質問] [🔍 検索] [ℹ️ ヘルプ]

TODOリスト(期限別表示中):

📋 【タスク一覧】

1. ⏳ 資料準備 (期限: 11/5)
2. ✅ 会議設定 (期日未定)

[📅 日別表示] [👤 担当者別] [📋 メニュー]

Phase 6.4: TODO Done/Undone/Delete Commands(完了 - 2025年10月30日)

達成事項

  • /sv:done, /sv:undone, /sv:delete コマンド実装
  • ✅ 対話型UI(Quick Reply選択)
  • ✅ 一貫したソート順(sortTodos関数)
  • ✅ インデックスベース操作(getTodoByIndex関数)
  • ✅ 実機テスト成功

3ステップフロー

Step 1: コマンド実行 → TODO一覧 + Quick Replyボタン

📋 【完了するTODOを選択】

1. ⏳ 資料を準備する (期限: 11/5)
2. ⏳ 会議を設定する (期日未定)

━━━━━━━━━━━━━
👇 完了するTODOをタップ

[1] [2] [3] ... (Quick Replyボタン)

Step 2: 番号選択 → 確認メッセージ

✅ TODO 1 を完了にしました!
「資料を準備する」

確認: /sv:todo

Step 3: 結果確認 → 更新されたTODOリスト

📋 【タスク一覧】

1. ⏳ 会議を設定する (期日未定)

✅ 完了済み:
- 資料を準備する

実装内容

handleDoneCommand (lib/commands.ts):

export async function handleDoneCommand(
  cmd: ParsedCommand,
  ctx: CommandContext
): Promise<Result<CommandResponse, string>> {
  const { args } = cmd
  const { groupId, db } = ctx

  // Step 1: No args → Show all TODOs with Quick Reply
  if (args.length === 0) {
    const allTodos = await db.getTodosByGroupId(groupId)
    const sortedTodos = sortTodos(allTodos.value)
    const quickReply: QuickReplyButton[] = sortedTodos
      .filter(t => t.status !== 'completed')
      .slice(0, 13)
      .map((t, i) => ({
        label: `${i + 1}`,
        text: `/sv:done ${i + 1}`,
      }))

    return {
      ok: true,
      value: {
        text: todoList,
        quickReply,
      },
    }
  }

  // Step 2: Parse index and execute
  const index = parseInt(args[0], 10) - 1
  const todo = getTodoByIndex(allTodos.value, index)

  if (!todo) {
    return { ok: false, error: 'TODOが見つかりません' }
  }

  await db.completeTodo(todo.id)

  return {
    ok: true,
    value: `✅ TODO ${index + 1} を完了にしました!\n「${todo.task}」\n\n確認: /sv:todo`,
  }
}

Phase 6.5: TODO Edit Feature(完了 - 2025年11月2日)

達成事項

  • /sv:edit コマンドの対話型UI実装
  • ✅ 3ステップフロー(一覧 → 選択 → 編集)
  • ✅ 複数フィールド編集対応(task/deadline/assignee)
  • ✅ Quick Reply選択UI(最大13個)
  • ✅ 実機テスト成功(タスク内容編集を確認)

編集可能フィールド

  • task: タスク内容(複数単語対応、args.slice(i + 1).join(' ')
  • deadline: 期限日付(YYYY-MM-DD形式、または 'none'/'null'/'削除' でクリア)
  • assignee: 担当者名(@記号を自動削除)

実装内容 (lib/commands.ts):

export async function handleEditCommand(
  cmd: ParsedCommand,
  ctx: CommandContext
): Promise<Result<CommandResponse, string>> {
  const { args } = cmd
  const { groupId, db } = ctx

  // Step 1: No args → Show all TODOs with Quick Reply
  if (args.length === 0) {
    const sortedTodos = sortTodos(allTodos)
    const quickReply: QuickReplyButton[] = sortedTodos.slice(0, 13).map((t, i) => ({
      label: `${i + 1}`,
      text: `/sv:edit ${i + 1}`,
    }))
    return { ok: true, value: { text: todoList, quickReply } }
  }

  // Step 2: Only index provided → Show edit instructions
  if (args.length === 1) {
    return {
      ok: true,
      value: `✏️ TODO ${index + 1} を編集します
「${todo.task}」

編集方法:
━━━━━━━━━━━━━
📝 内容を変更:
/sv:edit ${index + 1} task 新しいタスク内容

📅 期限を変更:
/sv:edit ${index + 1} deadline 2025-11-05

👤 担当者を変更:
/sv:edit ${index + 1} assignee 田中`,
    }
  }

  // Step 3: Parse field and value, execute update
  const updates: { task?: string; assignee?: string; deadline?: Date | null } = {}

  for (let i = 1; i < args.length; i++) {
    const field = args[i].toLowerCase()

    if (field === 'task' && i + 1 < args.length) {
      updates.task = args.slice(i + 1).join(' ') // Multi-word support
      break
    } else if (field === 'deadline' && i + 1 < args.length) {
      const dateValue = args[i + 1]
      if (['none', 'null', '削除'].includes(dateValue)) {
        updates.deadline = null
      } else {
        const deadlineDate = new Date(dateValue)
        const jstDate = new Date(deadlineDate.getTime() + 9 * 60 * 60 * 1000)
        jstDate.setHours(23, 59, 59, 999)
        updates.deadline = jstDate
      }
      i++
    } else if (field === 'assignee' && i + 1 < args.length) {
      updates.assignee = args[i + 1].replace('@', '')
      i++
    }
  }

  const result = await db.updateTodo(todoId, updates)

  return {
    ok: true,
    value: `✏️ TODO ${index + 1} を更新しました!
「${todo.task}${Object.entries(updates).map(([k, v]) => `${k}: ${v}`).join('\n')}

確認: /sv:todo`,
  }
}

実機テスト結果

テスト1: タスク内容編集

入力: /sv:edit 1 task 新しいタスク内容
出力: ✅ 成功「資料を準備する必要がある」→「新しいタスク内容」

Phase 6の成果

UX改善の効果

修正前:

  • ユーザーIDで表示(U1234567890)
  • 期限なしTODOの曖昧な表示
  • 複雑なコマンド構文の暗記が必要
  • テキスト入力のみの操作

修正後:

  • ユーザー名で表示(@林田夏樹)
  • 期限の明確な表示(期日未定、期限: 11/5)
  • メニューシステム + 番号ショートカット
  • Quick Replyボタンでタップ操作
  • 日本語コマンド対応
  • 3ステップ対話型UI

技術的成果

  • LINE Profile API統合(24時間TTLキャッシュ)
  • バッチプロフィール取得による効率化
  • ノンブロッキングなキャッシュ更新
  • Union型(CommandResponse)による型安全性
  • 3ステップフロー実装(一覧 → 選択 → 実行)
  • 複数フィールド編集対応
  • 柔軟な期限クリア('none'/'null'/'削除')
  • JST期限設定(23:59:59.999に自動設定)

操作性向上

修正前の操作:

ユーザー: /sv:edit todo 1 --deadline 2025-11-05 --task 新しいタスク

修正後の操作:

ユーザー: /sv:編集
Bot: [1] [2] [3] ...(Quick Replyボタン)
ユーザー: [タップで1を選択]
Bot: 編集方法を表示
ユーザー: /sv:edit 1 task 新しいタスク
Bot: ✅ 更新完了

→ Phase 7計画: 収益化・リリースへ


Phase 6完了日: 2025年11月2日 主な技術的成果: LINE Quick Reply Buttons、3ステップ対話型UI、複数フィールド編集、日本語エイリアス対応

#UX改善#LINE Quick Reply#インタラクティブUI#TODO管理#日本語対応

©2025 Natsuki Hayashida. All Rights Reserved.