AI

OpenAI Decisions API入門——AIの高速判断をFirestoreで監査する

OpenAI Decisions API betaを題材に、Next.js + FirestoreでAIの分類・選択・スコアリングを監査可能な業務判断フローへ組み込む実践手順を解説します。

2026年10月7日
OpenAIDecisions APIGPT-6 LunaNext.jsFirestore
OpenAI Decisions API入門——AIの高速判断をFirestoreで監査する

はじめに

問い合わせ、承認依頼、PRレビューの振り分けをAIに任せたいとき、毎回長い自然文を生成させる必要はありません。

場面本当にほしい出力
問い合わせ受付緊急度が高いか、どの担当キューへ送るか
バグ報告の一次分類再現性、影響範囲、要エスカレーション
PRレビューの事前確認セキュリティ観点で人間レビューを必須にするか

OpenAI APIのChangelogでは、2026年10月6日に Decisions API がbetaとして公開され、gpt-6-luna でテキストと画像を型付き回答へ変換できると案内されています。通常のResponses APIで文章を生成してJSONへ寄せるのではなく、predicate、choice、scoreのような「判断の形」を先に決めて問い合わせるのが特徴です。

本記事では、架空の問い合わせ管理SaaS「RouteDesk」を題材に、Next.js App Router + Firestore + TypeScriptで「AI判断の監査台帳」を作ります。この記事を読み終えると、AIの判断結果をただ画面に出すだけでなく、入力、質問定義、回答、confidence、利用者、後続アクションをFirestoreへ保存し、後から運用改善できる形にできます。

参考にした一次情報は次の通りです。

何が新しいのか

公式のAPI Referenceでは、POST /v1/decisions は共通の入力に対して、順序付きの分類・スコアリング質問を評価し、回答を質問順に返すAPIとして説明されています。入力は文字列、またはuser messageの配列で、テキストとインライン画像を扱えます。外部URL、file ID、audio、tool呼び出しなどは対象外です。

質問タイプは大きく3つです。

type用途返ってくる主な値
predicate真偽に近い条件判定probability
choice事前定義した選択肢から選ぶchoice, confidence, probabilities
score段階評価するscore, confidence, probabilities

gpt-6-luna は高頻度で焦点の定まった処理向けの効率モデルとして案内されており、Decisions APIのbeta公開時にもこのモデルが使われています。自由文生成ではなく「高頻度な小さな判断」に寄せると、業務アプリの中で扱いやすくなります。

ここで重要なのは、AIの判断を「最終決裁」にしないことです。RouteDeskではconfidenceが低い、またはセキュリティや契約に関わる場合、人間レビューへ送るルールをアプリ側に持たせます。

事前準備

Decisions APIはHTTPで呼び出せるため、ここではSDKの新メソッド名を推測せず、公式API Referenceで確認できる POST /v1/decisions を fetch で呼びます。サーバー側だけでAPIキーを扱う前提です。

yarn add firebase-admin
OPENAI_API_KEY=sk-...

RouteDeskでは、判断対象を supportTickets、AI判断ログを decisionRuns に保存します。問い合わせ本文そのものを長期保存したくない場合は、本文のハッシュや抜粋だけを残す設計にしてください。OpenAIのData controlsでは、/v1/decisions がサポート対象サービスとして掲載され、地域ごとの処理条件も案内されています。

ハンズオン1: Decisions APIを薄い関数にする

まず、問い合わせ本文を受け取り、3つの質問をまとめて投げる関数を作ります。質問名は後でFirestore集計しやすいよう、英数字の固定値にします。

// src/lib/ai/createRouteDeskDecision.ts
type DecisionAnswer =
  | {
      type: "predicate";
      name: string | null;
      probability: number;
    }
  | {
      type: "choice";
      name: string | null;
      choice: string | boolean;
      confidence: number;
      probabilities: Array<{ value: string | boolean; probability: number }>;
    }
  | {
      type: "score";
      name: string | null;
      score: number;
      confidence: number;
      probabilities: Array<{ label: string; value: number; probability: number }>;
    }
  | {
      type: "refusal";
      name: string | null;
    };

type DecisionResponse = {
  model: "gpt-6-luna";
  answers: DecisionAnswer[];
  usage?: {
    input_tokens: number;
    output_tokens: number;
    total_tokens: number;
  };
};

export async function createRouteDeskDecision(input: {
  title: string;
  body: string;
  customerPlan: "free" | "team" | "enterprise";
}) {
  const response = await fetch("https://api.openai.com/v1/decisions", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      model: "gpt-6-luna",
      input:
        `Customer plan: ${input.customerPlan}\n` +
        `Title: ${input.title}\n` +
        `Body: ${input.body}`,
      questions: [
        {
          type: "predicate",
          name: "needs_escalation",
          instructions:
            "Does this ticket mention security, data loss, billing outage, or an enterprise customer blocked in production?",
        },
        {
          type: "choice",
          name: "routing_queue",
          instructions: "Select the best support queue for the ticket.",
          choices: [
            { value: "billing", description: "Invoices, plans, payment, refunds" },
            { value: "technical", description: "Bugs, integrations, API errors" },
            { value: "success", description: "How-to questions and onboarding" },
          ],
        },
        {
          type: "score",
          name: "urgency",
          instructions: "Rate operational urgency from low to critical.",
          levels: [
            { label: "low", description: "Can wait for normal business hours" },
            { label: "medium", description: "Impacts one user or non-critical flow" },
            { label: "high", description: "Blocks a team or paid workflow" },
            { label: "critical", description: "Security, data loss, or production outage" },
          ],
        },
      ],
      safety_identifier: "routedesk-support",
    }),
  });

  if (!response.ok) {
    throw new Error(`OpenAI Decisions API failed: ${response.status}`);
  }

  return (await response.json()) as DecisionResponse;
}

ポイントは、選択肢をアプリ側で固定することです。AIに「いい感じに分類して」と頼むのではなく、業務で使うキューだけを選ばせます。これにより、後続のFirestoreクエリやGitHub Issue連携が単純になります。

ハンズオン2: Firestoreに判断台帳を残す

次に、Route Handlerで問い合わせを受け取り、判断結果を decisionRuns へ保存します。confidenceが十分高いときだけ自動振り分けし、それ以外は人間確認へ回します。

// src/app/api/tickets/[ticketId]/decision/route.ts
import { FieldValue } from "firebase-admin/firestore";
import { NextRequest, NextResponse } from "next/server";
import { adminDb } from "@/lib/firebase/admin";
import { createRouteDeskDecision } from "@/lib/ai/createRouteDeskDecision";

type Params = {
  params: Promise<{ ticketId: string }>;
};

type RequestBody = {
  title: string;
  body: string;
  customerPlan: "free" | "team" | "enterprise";
};

export async function POST(request: NextRequest, { params }: Params) {
  const { ticketId } = await params;
  const body = (await request.json()) as RequestBody;

  if (!body.title || !body.body || body.body.length > 10_000) {
    return NextResponse.json(
      { error: "title and body are required" },
      { status: 400 },
    );
  }

  const runRef = adminDb.collection("decisionRuns").doc();

  await runRef.set({
    ticketId,
    provider: "openai",
    model: "gpt-6-luna",
    endpoint: "/v1/decisions",
    status: "running",
    questionNames: ["needs_escalation", "routing_queue", "urgency"],
    createdAt: FieldValue.serverTimestamp(),
    updatedAt: FieldValue.serverTimestamp(),
  });

  try {
    const decision = await createRouteDeskDecision(body);
    const routing = decision.answers.find(
      (answer) => answer.name === "routing_queue" && answer.type === "choice",
    );
    const canAutoRoute =
      routing?.type === "choice" &&
      typeof routing.choice === "string" &&
      routing.confidence >= 0.85;

    await runRef.update({
      status: "completed",
      answers: decision.answers,
      usage: decision.usage ?? null,
      autoRoute: canAutoRoute
        ? { queue: routing.choice, confidence: routing.confidence }
        : null,
      updatedAt: FieldValue.serverTimestamp(),
    });

    await adminDb.collection("supportTickets").doc(ticketId).set(
      {
        aiDecisionRunId: runRef.id,
        routingQueue: canAutoRoute ? routing.choice : "manual_review",
        updatedAt: FieldValue.serverTimestamp(),
      },
      { merge: true },
    );

    return NextResponse.json({
      runId: runRef.id,
      answers: decision.answers,
      routingQueue: canAutoRoute ? routing.choice : "manual_review",
    });
  } catch (error) {
    await runRef.update({
      status: "failed",
      errorMessage: error instanceof Error ? error.message : "unknown error",
      updatedAt: FieldValue.serverTimestamp(),
    });

    return NextResponse.json({ error: "decision failed" }, { status: 502 });
  }
}

answers をそのまま保存しておくと、あとから「billingへ振り分けたが、technicalの確率も高かった」ケースを分析できます。自動化の成否は正解率だけでなく、保留率、再振り分け率、顧客プラン別の偏りも見る必要があります。

ハンズオン3: GitHub Actionsでしきい値を点検する

Decisions APIを導入した直後は、しきい値を固定して終わりにしない方が安全です。RouteDeskでは、匿名化した評価データを使い、毎朝decision結果の分布を確認するジョブを用意します。

name: Decision routing smoke test

on:
  schedule:
    - cron: "0 21 * * *"
  workflow_dispatch:

jobs:
  route-samples:
    runs-on: ubuntu-latest
    steps:
      - name: Check enterprise outage routing
        run: |
          curl -sS -X POST "$ROUTEDESK_BASE_URL/api/tickets/sample-001/decision" \
            -H "Authorization: Bearer $ROUTEDESK_SMOKE_TOKEN" \
            -H "Content-Type: application/json" \
            -d '{
              "title":"本番APIが500を返し続けています",
              "body":"enterprise契約の複数ユーザーがログイン後に請求画面を開けません。",
              "customerPlan":"enterprise"
            }'

このジョブはOpenAIのAPIを直接叩くのではなく、自社のRoute Handlerを通します。認証、入力検証、Firestore保存、しきい値判定まで含めて確認できるためです。実データは使わず、サンプルチケットだけで「critical寄りの入力がmanual_reviewに落ち続けていないか」を見ます。

運用で見るべき指標

Decisions APIは高速な判断に向いていますが、プロダクトで大事なのは「自動化したあとに事故らないこと」です。最低限、次の指標をFirestoreから集計できるようにしておきます。

指標見る理由
routing_queue のconfidence分布自動振り分けしきい値を調整するため
refusal 件数入力や質問設計が不適切なケースを見つけるため
manual_review 率AIに任せすぎていないか、逆に保留しすぎていないかを見るため
人間による再振り分け率実務上の正解率を測るため
usage.total_tokens高頻度処理のコストを見積もるため

もしconfidenceだけで自動化すると危ない領域、たとえばセキュリティ、法務、返金、個人情報削除などがあるなら、predicateの結果にかかわらず人間レビューを必須にします。AI判断は便利な分岐器ですが、責任境界はアプリ側で明示するのが実務向きです。

まとめ

Decisions APIは、AIに長文回答を書かせるためのAPIというより、アプリケーションの中に小さな判断を高速に差し込むための部品です。predicate、choice、scoreを使い分けると、問い合わせ振り分け、優先度判定、レビュー要否のような処理を型付きで扱えます。

RouteDeskのようなNext.js + Firestore構成では、判断結果をその場で使うだけでなく、decisionRuns として保存するのがポイントです。入力、質問定義、回答、confidence、usage、自動化した後続処理を残しておけば、後から「どの判断をAIに任せてよいか」を数字で見直せます。

まずは1つの業務フローで、confidenceが高いケースだけ自動化するのがおすすめです。AIにすべてを決めさせるのではなく、人間レビューへ戻す余白を残すことで、速さと安全性の両方を取りにいけます。