mybook

トレーニング:PoC 構築ワークショップ — ゼロから72時間で動くものを作る

72時間の挑戦

「ソウタ、来週の月曜から水曜まで、3日間を空けておけ」

金曜の夕方、退勤しようとしていたソウタに、カイが声をかけた。手には一枚の資料が握られている。

「中堅 SaaS 企業の TaskFlow から PoC 依頼が来た。プロジェクト管理ツールに AI 機能を統合したいらしい。月曜のキックオフから水曜のデモまで、72時間で動くものを見せる」

ソウタは目を見開いた。「72時間って……3日ですよね? Rails アプリの立ち上げから AI 統合、デモ環境の構築まで?」

「そうだ。FDE にとって PoC ワークショップは最も価値のある72時間だ。顧客が『これは使える』と確信する瞬間を作り出す。今回はお前がリードだ。俺はメンターとして横に付く」

カイは資料をソウタに渡した。「まずは Day 0——準備から始めるぞ」


Day 0:準備(2時間)

顧客ブリーフィング資料の読み込み

カイがソウタに渡した資料には、TaskFlow の概要がまとめられていた。

TaskFlow 社概要

  • 従業員数:120名(エンジニア40名)
  • プロダクト:プロジェクト管理 SaaS(MAU 5万)
  • 技術スタック:Rails 7 / PostgreSQL / Redis / Heroku
  • 課題:タスクの手動分類に時間がかかる、進捗リスクの早期検知ができない
  • 希望:Arclight AI の API を使って AI 機能を追加したい

「まず顧客のペインポイントを正確に理解する。技術的にできることではなく、顧客が困っていることから入れ」

ソウタは3つの核心課題を整理した。

  1. タスク分類の自動化 — 毎日100件以上のタスクを手動でカテゴリ分けしている
  2. 優先度の自動提案 — 締切・依存関係・チームの負荷から優先度を判断したい
  3. 進捗リスクの早期検知 — 遅延しそうなプロジェクトを事前に警告してほしい

環境構築チェックリスト

「準備段階で環境を完璧にしておけ。Day 1 で環境構築に時間を食うのは最悪のパターンだ」

  • ✅ Ruby 3.3 + Rails 7.1 のインストール確認
  • ✅ PostgreSQL 16 / Redis 7 のローカルインスタンス起動
  • ✅ Arclight AI API キーの発行と curl での動作確認
  • ✅ AWS CLI + Terraform のセットアップ
  • ✅ Docker Desktop の起動確認
  • ✅ GitHub リポジトリ taskflow-ai-poc の作成

WARNING

API キーの動作確認は必ず Day 0 で済ませること。当日になって「API キーが無効でした」は致命的。curl で簡単なリクエストを送り、レスポンスが返ることを確認する。

SMART 成功基準の設計

基準内容
SpecificAI 分類・優先度提案・リスク検知の3機能が動作する
Measurableタスク分類の精度 80% 以上、API レスポンス 2秒以内
Achievable既存の Arclight AI API の機能範囲内で実現可能
RelevantTaskFlow の3つの核心課題を直接解決する
Time-bound72時間以内にデモ可能な状態にする

INFO

成功基準は顧客と事前に合意しておく。PoC 終了後に「思っていたのと違う」と言われるのを防ぐ。Day 1 のキックオフ冒頭で確認する。


Day 1:スコーピング & プロトタイプ(8時間)

Hour 1-2:テクニカルディスカバリー

月曜日の朝9時。TaskFlow のオフィスで、CTO の田中さんとエンジニアリードの佐藤さんと向き合った。

「最初の2時間は質問の時間です。こちらから提案する前に、御社の現状を正確に理解させてください」

データ構造の質問:

  • タスクのカテゴリは何種類か?(→ 8カテゴリ)
  • 過去の分類データは何件あるか?(→ 約10万件、2年分)
  • タスクの平均テキスト長は?(→ タイトル20文字 + 説明100文字)

インフラ・セキュリティの質問:

  • 外部 API 呼び出しの制約は?(→ ユーザー体感3秒以内)
  • データの外部送信ポリシーは?(→ タスクタイトルと説明文のみ可、個人情報は除外)

Hour 3-4:アーキテクチャ設計

ディスカバリーの結果を踏まえ、ソウタはホワイトボードにアーキテクチャを描いた。

Loading diagram...

「Rails アプリから Service クラス経由で Arclight API を呼び、結果を PostgreSQL に保存。頻繁なリクエストは Redis でキャッシュ。フロントは Turbo Streams でリアルタイム反映します」

田中 CTO が頷いた。「うちの技術スタックと親和性が高いな」

WARNING

PoC のアーキテクチャで最も大事なのは既存技術スタックとの親和性。顧客のエンジニアが「これなら自分たちでも保守できる」と思えることが本番採用の鍵。

Hour 5-8:Rails スケルトン構築

午後1時。ソウタはコードを書き始めた。まずはマイグレーションとモデル。

# db/migrate/002_create_tasks.rb
class CreateTasks < ActiveRecord::Migration[7.1]
  def change
    create_table :tasks do |t|
      t.references :project, null: false, foreign_key: true
      t.string :title, null: false
      t.text :description
      t.string :category
      t.integer :priority, default: 0
      t.string :status, default: "open"
      t.string :assigned_to
      t.date :due_date
      t.timestamps
    end
 
    add_index :tasks, %i[project_id status]
  end
end
 
# db/migrate/003_create_ai_suggestions.rb
class CreateAiSuggestions < ActiveRecord::Migration[7.1]
  def change
    create_table :ai_suggestions do |t|
      t.references :task, null: false, foreign_key: true
      t.string :suggestion_type, null: false
      t.jsonb :payload, default: {}
      t.float :confidence, default: 0.0
      t.string :status, default: "pending"
      t.datetime :accepted_at
      t.timestamps
    end
  end
end
# app/models/task.rb
class Task < ApplicationRecord
  belongs_to :project
  has_many :ai_suggestions, dependent: :destroy
 
  validates :title, presence: true
  enum :priority, { low: 0, medium: 1, high: 2, critical: 3 }
 
  scope :open_tasks, -> { where(status: "open") }
  scope :overdue, -> { where(status: "open").where("due_date < ?", Date.current) }
end
 
# app/models/ai_suggestion.rb
class AiSuggestion < ApplicationRecord
  belongs_to :task
  validates :suggestion_type, inclusion: { in: %w[classification priority risk_alert] }
 
  scope :pending, -> { where(status: "pending") }
 
  def accept!
    update!(status: "accepted", accepted_at: Time.current)
  end
end

次に Arclight AI との接続を Service Object パターンで実装した。

# app/services/arclight_client.rb
class ArclightClient
  BASE_URL = ENV.fetch("ARCLIGHT_API_URL", "https://api.arclight.ai/v1")
 
  class ApiError < StandardError; end
  class RateLimitError < ApiError; end
 
  def initialize
    @api_key = ENV.fetch("ARCLIGHT_API_KEY")
    @conn = Faraday.new(url: BASE_URL) do |f|
      f.request :json
      f.response :json
      f.request :retry, max: 2, interval: 0.5
      f.options.timeout = 10
    end
  end
 
  def classify(text, categories:)
    request(:post, "/classify", { text: text, categories: categories, language: "ja" })
  end
 
  def suggest_priority(task_data)
    request(:post, "/analyze/priority", task_data)
  end
 
  def detect_risk(project_data)
    request(:post, "/analyze/risk", project_data)
  end
 
  private
 
  def request(method, path, body)
    response = @conn.send(method, path) do |req|
      req.headers["Authorization"] = "Bearer #{@api_key}"
      req.body = body
    end
 
    case response.status
    when 200..299 then response.body
    when 429 then raise RateLimitError, "レートリミット超過"
    else raise ApiError, "API エラー: #{response.status}"
    end
  rescue Faraday::TimeoutError
    raise ApiError, "Arclight API タイムアウト"
  end
end

AI 機能をまとめるサービスクラスも作成した。

# app/services/task_classifier.rb
class TaskClassifier
  CATEGORIES = %w[バグ修正 機能追加 改善 インフラ セキュリティ パフォーマンス リファクタリング ドキュメント].freeze
 
  def initialize(client: ArclightClient.new)
    @client = client
  end
 
  def classify(task)
    cache_key = "classify:#{task.id}:#{task.updated_at.to_i}"
 
    result = Rails.cache.fetch(cache_key, expires_in: 1.hour) do
      @client.classify("#{task.title} #{task.description}", categories: CATEGORIES)
    end
 
    task.ai_suggestions.create!(
      suggestion_type: "classification",
      payload: { category: result["category"], alternatives: result["alternatives"] },
      confidence: result["confidence"]
    )
  rescue ArclightClient::ApiError => e
    Rails.logger.error("[TaskClassifier] #{e.message}")
    nil
  end
end

Day 1 完了チェックリスト:

  • ✅ テクニカルディスカバリー完了
  • ✅ アーキテクチャ設計と顧客合意
  • ✅ 3つのモデル(Project, Task, AiSuggestion)作成
  • ✅ Arclight AI API クライアント実装
  • ✅ タスク分類・優先度提案サービス実装
  • ✅ シードデータで基本動作確認

Day 2:機能実装 & テスト(8時間)

Hour 1-3:コア機能の実装

火曜日の朝。自然言語タスク作成とリスク検知に取りかかった。

# app/services/natural_language_task_creator.rb
class NaturalLanguageTaskCreator
  def initialize(client: ArclightClient.new)
    @client = client
  end
 
  def create(project:, natural_text:)
    parsed = @client.classify(natural_text, categories: ["task_parsing"])
 
    task = project.tasks.create!(
      title: parsed.dig("parsed", "title") || natural_text.truncate(100),
      description: parsed.dig("parsed", "description"),
      due_date: safe_parse_date(parsed.dig("parsed", "due_date"))
    )
 
    AiAnalysisJob.perform_later(task.id)
    task
  end
 
  private
 
  def safe_parse_date(str)
    str.present? ? Date.parse(str) : nil
  rescue Date::Error
    nil
  end
end
# app/jobs/ai_analysis_job.rb
class AiAnalysisJob < ApplicationJob
  queue_as :ai_analysis
  retry_on ArclightClient::RateLimitError, wait: 30.seconds, attempts: 3
 
  def perform(task_id)
    task = Task.find(task_id)
    TaskClassifier.new.classify(task)
 
    Turbo::StreamsChannel.broadcast_replace_to(
      "project_#{task.project_id}_tasks",
      target: "task_#{task.id}",
      partial: "tasks/task",
      locals: { task: task.reload }
    )
  end
end

Hour 4-6:フロントエンド統合

「Turbo Streams でリアルタイム感を出す。SPA を作らなくても十分だ」

# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
  before_action :set_project
 
  def create
    @task = if params[:natural_text].present?
              NaturalLanguageTaskCreator.new.create(
                project: @project, natural_text: params[:natural_text]
              )
            else
              @project.tasks.create!(task_params).tap do |t|
                AiAnalysisJob.perform_later(t.id)
              end
            end
 
    respond_to do |format|
      format.turbo_stream
      format.html { redirect_to project_tasks_path(@project) }
    end
  end
 
  def accept_suggestion
    task = @project.tasks.find(params[:id])
    suggestion = task.ai_suggestions.find(params[:suggestion_id])
    suggestion.accept!
 
    case suggestion.suggestion_type
    when "classification"
      task.update!(category: suggestion.payload["category"])
    when "priority"
      task.update!(priority: suggestion.payload["suggested_priority"])
    end
 
    respond_to do |format|
      format.turbo_stream
      format.html { redirect_to project_task_path(@project, task) }
    end
  end
 
  private
 
  def set_project = @project = Project.find(params[:project_id])
  def task_params = params.require(:task).permit(:title, :description, :due_date)
end

INFO

Turbo Streams + Stimulus の組み合わせは PoC で最強の武器。SPA を作る必要がなく、Rails の既存テンプレートエンジンの延長でリアルタイム更新が実現できる。顧客エンジニアにとっても学習コストが低い。

Hour 7-8:テスト & バグ修正

「動くものを作るだけじゃない。壊れないことを証明するテストも PoC に含める」

# spec/services/task_classifier_spec.rb
RSpec.describe TaskClassifier do
  let(:client) { instance_double(ArclightClient) }
  let(:classifier) { described_class.new(client: client) }
  let(:task) { create(:task, title: "ログイン画面が表示されない") }
 
  context "API が正常にレスポンスを返す場合" do
    before do
      allow(client).to receive(:classify).and_return(
        { "category" => "バグ修正", "confidence" => 0.92, "alternatives" => [] }
      )
    end
 
    it "AiSuggestion を作成する" do
      expect { classifier.classify(task) }.to change { task.ai_suggestions.count }.by(1)
    end
 
    it "正しいカテゴリと confidence を保存する" do
      suggestion = classifier.classify(task)
      expect(suggestion.payload["category"]).to eq("バグ修正")
      expect(suggestion.confidence).to eq(0.92)
    end
  end
 
  context "API がエラーを返す場合" do
    before do
      allow(client).to receive(:classify)
        .and_raise(ArclightClient::ApiError, "Internal Server Error")
    end
 
    it "nil を返しクラッシュしない" do
      expect(classifier.classify(task)).to be_nil
    end
  end
end

WARNING

PoC のテストはハッピーパス + エラーハンドリングの2軸で書く。カバレッジ100%は不要だが、「API が落ちてもアプリはクラッシュしない」ことを証明するテストは必須。

Day 2 完了チェックリスト:

  • ✅ 自然言語タスク作成機能
  • ✅ プロジェクトリスク検知機能
  • ✅ Turbo Streams によるリアルタイム更新
  • ✅ RSpec テスト(サービス層)
  • ✅ エッジケース処理(API エラー、タイムアウト、空データ)

Day 3:デモ準備 & プレゼン(8時間)

Hour 1-3:AWS デモ環境の構築

水曜日の朝。ソウタは AWS 上にデモ環境を構築した。

「デモはローカルでやるな。本番に近い環境で動かすことで、『すぐに本番投入できる』と思わせる」

Loading diagram...

INFO

PoC のデモ環境はコスト最小限に。ECS Fargate は 0.25 vCPU / 512MB、RDS は db.t4g.micro、ElastiCache は cache.t4g.micro で十分。Terraform で管理し、デモ後にすぐ destroy できるようにしておく。

WARNING

デモ環境構築で最もハマりやすいのはセキュリティグループの設定。ECS → RDS、ECS → ElastiCache、ALB → ECS の3つのインバウンドルールを忘れると接続できない。チェックリストにしておく。

Hour 4-5:デモスクリプトの作成

「デモはストーリーだ。機能を羅列するんじゃない。顧客の課題が解決される物語を見せる」

デモスクリプト(15分構成)

Act 1:課題の再確認(2分) 「御社では毎日100件以上のタスクを手動で分類されていると伺いました」 → 顧客に自分の言葉で課題を語ってもらう

Act 2:AI 分類のデモ(4分) タスクを1件作成 → 数秒後に AI が自動分類(Turbo Streams でリアルタイム更新)→ 承認/却下を実演。「手動30秒 × 100件 = 50分が、AI なら5分に短縮」

Act 3:自然言語タスク作成(3分) 「ログイン画面のレスポンスが遅いので来週金曜までに改善してほしい」と入力 → AI がタイトル・期限・カテゴリを自動設定。「Slack の依頼をそのまま貼るだけ」

Act 4:リスク検知(3分) プロジェクトダッシュボード → AI が遅延リスクをハイライト表示。「炎上する前に手を打てます」

Act 5:次のステップ(3分) 本番導入ロードマップ + 質疑応答

Hour 6-7:カイとのリハーサル

「よし、通しでやってみろ。俺が田中 CTO 役をやる」

5分後、カイが止めた。「3つ問題がある」

  1. 画面遷移が多すぎる — デモは1画面で完結させろ
  2. 沈黙の時間がある — AI の処理待ち中も「今 AI が分析しています」と実況しろ
  3. 数字がない — 「速くなります」ではなく具体的な時間短縮を言え

ソウタはスクリプトを修正し、もう一度通した。

「今度はいい。だが最後に——障害シナリオを用意しておけ。デモ中に API が落ちたらどうなるか見せられるようにしろ」

WARNING

デモ中の頻出失敗は「Wi-Fi 切断」「API タイムアウト」「データ不整合」の3つ。全シナリオでグレースフルデグラデーションすることを確認しておく。AI が落ちてもタスク自体は作成できる——その姿を見せられるのは逆に信頼を生む。

Hour 8:振り返りと次のステップ

デモは成功した。田中 CTO は身を乗り出して質問を繰り返し、佐藤エンジニアリードは「この Service Object のパターン、うちのコードベースにもそのまま入れられそう」と笑顔で言った。

Loading diagram...

KPT 振り返り:

区分内容
KeepService Object パターンで AI 機能を分離し、顧客エンジニアも理解しやすかった
KeepTurbo Streams のリアルタイム更新がデモのインパクトを高めた
ProblemDay 2 のフロントエンドに予想以上の時間がかかった
ProblemAWS セキュリティグループ設定で30分ロスした
Try次回は Terraform モジュールを事前にテンプレート化しておく

本番移行クオリティチェックリスト

「PoC で終わらせない。本番移行を見据えたチェックリストを顧客に渡せ。これが FDE の信頼を決定づける」

セキュリティ(5項目)

  • ✅ API キーは環境変数管理、コードにハードコードなし
  • ✅ AI に送信するデータから個人情報(PII)を除外
  • ✅ HTTPS のみで通信
  • ✅ API キーのローテーション手順を文書化
  • ✅ エラーメッセージに内部情報が漏洩しない

パフォーマンス(5項目)

  • ✅ AI API 呼び出しはバックグラウンドジョブで非同期処理
  • ✅ 頻繁なリクエストは Redis でキャッシュ
  • ✅ DB クエリに N+1 問題がない
  • ✅ API タイムアウトを適切に設定(10秒以内)
  • ✅ 負荷テスト(100 concurrent requests)で 3秒以内

信頼性(5項目)

  • ✅ API エラー時にアプリがクラッシュしない
  • ✅ リトライロジック実装(指数バックオフ)
  • ✅ サーキットブレーカー実装
  • ✅ ヘルスチェックエンドポイント設置
  • ✅ エラー監視(Sentry / Datadog)設定

運用(5項目)

  • ✅ ログに十分なコンテキスト(request_id, user_id)を含む
  • ✅ AI 提案の承認/却下率をメトリクスとして記録
  • ✅ API 利用量のアラート設定
  • ✅ ロールバック手順を文書化
  • ✅ オンコール対応のランブック作成

INFO

このチェックリストを PoC 完了時に顧客に渡すと2つの効果がある。①「本番のことまで考えてくれている」という信頼。②本番導入プロジェクトの受注につながるスコープの明確化。


タイムラインで遅れたとき——ショートカット戦略

「完璧な PoC は存在しない。時間が足りないとき、何を切り何を守るかの判断が FDE の腕の見せ所だ」

Day 1 で遅れた場合:

  • AiSuggestion を独立テーブルではなく Task の jsonb カラムに統合
  • API クライアントのリトライ処理を省略し、最小限のエラーハンドリングのみ
  • シードデータは CSV インポートスクリプトで一括投入

Day 2 で遅れた場合:

  • Turbo Streams を諦め、通常のフォーム送信 + ページリロードに切り替え
  • テストを Service 層のみに絞り、コントローラーテストは省略
  • リスク検知を外し、分類と優先度提案の2機能に集中

Day 3 で遅れた場合:

  • AWS デプロイを諦め、ngrok 経由のローカルデモに切り替え
  • デモスクリプトを15分から10分に短縮
  • リハーサルを1回に減らす(ただしゼロにはしない)

WARNING

何を切ってもいいが、デモのリハーサルだけは絶対にゼロにするな。練習なしのデモは事故る。最低1回は通しで練習する。


72時間の全体タイムライン

フェーズ時間内容成果物
Day 02h準備・環境構築チェックリスト、SMART基準
Day 1 H1-22hテクニカルディスカバリー要件リスト、質問回答記録
Day 1 H3-42hアーキテクチャ設計構成図、顧客合意
Day 1 H5-84hRails スケルトンモデル、API クライアント、サービス
Day 2 H1-33hコア機能実装NL タスク作成、リスク検知
Day 2 H4-63hフロントエンド統合Turbo Streams、Stimulus
Day 2 H7-82hテスト & バグ修正RSpec テスト
Day 3 H1-33hAWS デモ環境ECS、RDS、ElastiCache
Day 3 H4-52hデモスクリプト15分構成の台本
Day 3 H6-72hリハーサルフィードバック反映
Day 3 H81h振り返りKPT、チェックリスト

この章の学び

「72時間で動くものを作る——これが FDE の PoC ワークショップだ」

カイはオフィスの窓から夕焼けを見ながら言った。

「技術力だけでは足りない。時間管理、顧客コミュニケーション、デモのストーリーテリング。全部がひとつになって初めて、顧客の心を動かす PoC になる」

ソウタは今回のワークショップで学んだことを整理した。

  1. 準備が9割 — Day 0 の2時間が72時間の成否を決める
  2. Service Object パターン — AI 機能を分離し、テスト容易性と理解しやすさを両立
  3. Turbo Streams の威力 — SPA なしでリアルタイム感のあるデモが作れる
  4. グレースフルデグラデーション — AI が落ちてもアプリは動く設計が信頼を生む
  5. デモはストーリー — 機能の羅列ではなく、課題が解決される物語を見せる
  6. 完璧より完了 — 何を切るかの判断こそが FDE の価値

「次の PoC は、お前一人でやってもらうぞ」

カイの言葉に、ソウタは静かに頷いた。72時間で動くものを作る力——それは FDE としての自信そのものだった。