RAKSUL TechBlog

RAKSULグループのエンジニアが技術トピックを発信するブログです

マルチフロントエンド環境での統一認証基盤を実現する内部パッケージの設計と実装

はじめまして。エンジニアの小川です。弊社のAIマーケティングチームにてフルスタックで開発を行っています。
本記事では、マルチフロントエンド環境における認証基盤の課題とその解決策として開発した@novasell/auth-kitパッケージの設計思想と実装について詳しく解説します。

はじめに

弊社のAIマーケティングプラットフォームでは顧客ごとの要件や影響範囲を考慮して、機能別に独立したフロントエンドアプリケーションを構築しています。

このようなマルチフロントエンド環境では認証認可の導入において将来的に以下のような課題が見えていました:

  • 認証ロジックの重複実装によるメンテナンスコストの増大
  • 認可設定の一元管理の困難さ
  • Auth0設定の分散による設定ミスのリスク

これらの潜在的な課題を先回りして解決するため、私たちは@novasell/auth-kitという内部パッケージを開発しました。

システム構成と背景

マルチフロントエンド構成を採用した理由

私たちのプラットフォームが複数のフロントエンドアプリケーションで構成されているのには、いくつかの重要な理由があります:

  1. 顧客ごとのカスタマイズ性: 各顧客が必要とする機能だけを有効化し、不要な機能による複雑性を避ける
  2. 独立したリリースサイクル: 機能ごとに異なる開発速度やリリース頻度に対応
  3. 影響範囲の最小化: 一つの機能の変更が他の機能に影響を与えないアーキテクチャ

現在の構成は以下のようになっています:

frontend/
├── .env                  # 環境変数(全アプリで共有)
├── app_a/                # メイン機能アプリケーション
├── app_b/                # サブ機能アプリケーション
├── portal/               # ポータルサイト
├── dashboard/            # 分析ダッシュボード
└── packages/
    ├── auth-kit/         # 認証基盤パッケージ
    └── api-client/       # API通信パッケージ

このような構成では、各アプリケーションが独立している一方で、認証のような横断的な機能をどう実装するかが重要な課題となります。すべてのアプリケーションで個別に認証を実装すると、コードの重複や設定の不整合といったリスクがあります。

Auth0導入の経緯

すでに社内の他プロダクトではAuth0を使用した認証基盤が稼働していました。プロダクト間でのSSOを実現し、ユーザー体験を統一するため、このプラットフォームでも同じAuth0テナントを使用することにしました。

実現したかったこと

1. 柔軟な認可設定

アプリケーションが複数存在するため、以下のような認可制御が必要でした:

  • アプリレベル認可: ユーザーごとにアクセス可能なアプリケーションを制御
  • 認証のみモード: 一部のページでは認証のみで認可チェックをスキップ
  • ページ単位の制御: ページごとに異なる認証・認可レベルを設定

2. 開発者体験の向上

フロントエンド開発者が簡単に認証機能を導入できるよう、以下を実現しました:

  • 2つの導入パターン: シンプルな一括設定と細かい制御が可能な個別設定
  • TypeScript完全対応: 型安全な実装
  • 環境変数の集約: Auth0関連の設定を一元管理

3. セキュリティとユーザー体験

認証後のユーザー体験とセキュリティを両立させるため:

  • セキュアなリダイレクト: ログイン後、元々アクセスしようとしていたページへ安全に自動遷移
  • URL検証: ホワイトリスト方式でリダイレクト先を検証し、オープンリダイレクト脆弱性を防止

実装の詳細

コア実装

1. AuthProvider - 基盤となる認証プロバイダー

AuthProviderは、Auth0のSDKをラップし、アプリケーション全体に認証機能を提供する最も基礎的なコンポーネントです。このコンポーネントの主な責務は以下の通りです:

  • Auth0 SDKの初期化と設定
  • ログイン後のリダイレクト処理
  • セッション管理機能の統合
// src/AuthProvider.tsx
export const AuthProvider = ({ children }: AuthProviderProps) => {
  // ログイン後のリダイレクト処理
  const onRedirectCallback = (appState?: { returnTo?: string }) => {
    const fallback = getAllowedOrigin();
    const returnTo = appState?.returnTo || fallback;
    // セキュアなURL検証を行い、不正なリダイレクトを防止
    const safeReturnTo = getSafeReturnToUrl(returnTo);
    window.location.href = safeReturnTo;
  };

  return (
    <Auth0Provider
      domain={auth0Config.domain}
      clientId={auth0Config.clientId}
      authorizationParams={auth0Config.authorizationParams}
      useRefreshTokens={auth0Config.useRefreshTokens}
      cacheLocation={auth0Config.cacheLocation}
      onRedirectCallback={onRedirectCallback}
    >
      {children}
    </Auth0Provider>
  );
};

2. ProtectedRoute - 細粒度のアクセス制御

ProtectedRouteコンポーネントは、ページやコンポーネント単位で認証・認可を制御する重要な役割を担っています。このコンポーネントの特徴は、認証のみのチェックと、アプリケーションレベルの認可チェックを柔軟に切り替えられる点です。

// src/ProtectedRoute.tsx
export const ProtectedRoute = ({
  appName,
  children,
  authenticationOnly,  // 認証のみか、認可も必要か
  accessDeniedRedirectUrl,
  loadingComponent,
  errorComponent
}: ProtectedRouteProps) => {
  const { isLoading, isAuthenticated, error, login } = useAuth();
  const appAccessStatus = useAccessibleApp(appName);
  const loginInitiated = useRef(false);

  // 未認証の場合、自動的にログインページへ
  useEffect(() => {
    if (!isLoading && !isAuthenticated && !loginInitiated.current) {
      const returnTo = window.location.href;
      loginInitiated.current = true;
      login(returnTo); // 現在のURLを保持してログイン
    }
  }, [isLoading, isAuthenticated, login]);

  // ... 状態に応じた画面表示ロジック
};

3. AppAuthProvider - シンプルな一括設定

開発者の利便性を考慮して、AuthProviderとProtectedRouteを組み合わせたAppAuthProviderコンポーネントを提供しています。これにより、1つのコンポーネントでアプリケーション全体を保護できます:

// src/AppAuthProvider.tsx
export const AppAuthProvider = ({
  children,
  appName,
  authenticationOnly = false,
  accessDeniedRedirectUrl = `${window.location.origin}/access-denied`,
  loadingComponent,
  errorComponent
}: AppAuthProviderProps) => (
  <AuthProvider>
    <ProtectedRoute
      appName={appName}
      authenticationOnly={authenticationOnly}
      accessDeniedRedirectUrl={accessDeniedRedirectUrl}
      loadingComponent={loadingComponent}
      errorComponent={errorComponent}
    >
      {children}
    </ProtectedRoute>
  </AuthProvider>
);

認可システムの実装

アプリケーションレベルの認可は、バックエンドAPIと連携して実現しています:

// src/useAccessibleApp.ts
import { useUser } from './useUser';
import { type UserResponse } from '@novasell/api-client';

export type AppAccessStatus =
  | { status: 'checking' }
  | { status: 'granted', user: UserResponse }
  | { status: 'denied', user: UserResponse }
  | { status: 'error', error: Error };

export const useAccessibleApp = (appName: string): AppAccessStatus => {
  const { user, error } = useUser();

  if (!user) return { status: 'checking' };
  if (error) return { status: 'error', error };

  // ユーザーのアクセス可能アプリリストをチェック
  const hasAccess = user.accessible_apps.some(app => app.name === appName);

  return hasAccess
    ? { status: 'granted', user }
    : { status: 'denied', user };
};

環境変数の統一管理

auth-kitの大きな特徴の一つが、環境変数の統一管理です。Viteのビルド設定を工夫することで、パッケージ内からfrontendディレクトリのルートにある環境変数ファイルを読み込めるようにしました:

// vite.config.ts
export default defineConfig(({ mode }) => {
  // 現在のディレクトリから読み込み
  const currentEnv = loadEnv(mode, process.cwd(), '')
  // 一個上の階層から読み込み
  const parentEnv = loadEnv(mode, path.resolve(__dirname, '../'), '')

  // 環境変数をマージ
  const env = { ...parentEnv, ...currentEnv }

  return {
    // ... その他の設定
    define: {
      // VITE_で始まる環境変数をすべて定義
      ...Object.keys(env).reduce((prev: Record<string, string>, key) => {
        if (key.startsWith('VITE_')) {
          prev[`import.meta.env.${key}`] = JSON.stringify(env[key]);
        }
        return prev;
      }, {})
    }
  };
});

これにより、各アプリケーションは個別にAuth0の設定を持つ必要がなくなり、frontendディレクトリルートの.envファイルで一元管理できます:

# frontend/.env
VITE_AUTH0_DOMAIN=your-tenant.auth0.com
VITE_AUTH0_CLIENT_ID=your-client-id
VITE_AUTH0_AUDIENCE=https://api.example.com
VITE_AUTH0_REDIRECT_URI=http://localhost:3000

パッケージ側では、これらの環境変数を読み取って設定オブジェクトを生成します:

// src/config.ts
const getAuth0Config = (): Auth0Config => {
  const domain = import.meta.env.VITE_AUTH0_DOMAIN;
  const clientId = import.meta.env.VITE_AUTH0_CLIENT_ID;
  const audience = import.meta.env.VITE_AUTH0_AUDIENCE;

  // 必須設定値の検証
  if (!domain) throw new Error('VITE_AUTH0_DOMAIN is required');
  if (!clientId) throw new Error('VITE_AUTH0_CLIENT_ID is required');
  if (!audience) throw new Error('VITE_AUTH0_AUDIENCE is required');

  return {
    domain,
    clientId,
    authorizationParams: {
      redirect_uri: import.meta.env.VITE_AUTH0_REDIRECT_URI || '',
      audience,
      scope: 'openid profile email offline_access',
    },
    ...
  };
};

実際の利用方法

パターン1: AppAuthProvider(シンプルな一括設定)

アプリケーション全体に認証・認可を適用する場合に最適です。このパターンは、すべてのページで認証が必要なアプリケーションや、公開ページが存在しないアプリケーションに適しています:

// frontend/app_a/src/App.tsx
import { AppAuthProvider } from "@novasell/auth-kit";

const App = () => (
  <QueryClientProvider client={queryClient}>
    <AppAuthProvider appName="app_a">
      <TooltipProvider>
        <HashRouter>
          <Routes>
            <Route path="/" element={<Index />} />
            <Route path="*" element={<NotFound />} />
          </Routes>
        </HashRouter>
      </TooltipProvider>
    </AppAuthProvider>
  </QueryClientProvider>
);

このパターンの利点は以下の通りです:

メリット: - 実装の簡潔さ: わずか1つのコンポーネントの追加で認証・認可機能を実装 - 包括的な保護: アプリケーション全体が自動的に保護される - メンテナンスの容易さ: 認証ロジックの変更が一箇所で完結 - 設定の柔軟性: 必要に応じてカスタムローディング画面やエラー画面を設定可能

パターン2: AuthProvider + ProtectedRoute(細かい制御)

公開ページと保護ページが混在するアプリケーションや、ページごとに異なる認証レベルを設定したい場合に適しています。このパターンは、ポータルサイトやコーポレートサイトのように、一部のコンテンツは公開し、一部は認証を必要とする構成に最適です:

// frontend/portal/src/App.tsx
import { AuthProvider, ProtectedRoute } from '@novasell/auth-kit';

function App() {
  return (
    <AuthProvider>
      <Router>
        <Routes>
          {/* 公開ページ - 認証不要 */}
          <Route path="/" element={<HomePage />} />

          {/* 認証のみ必要なページ - ログインユーザーなら誰でもアクセス可 */}
          <Route path="/apps" element={
            <ProtectedRoute
              appName="portal"
              authenticationOnly={true}
              accessDeniedRedirectUrl="/access-denied"
            >
              <AppsPage />
            </ProtectedRoute>
          } />

          {/* 認可も必要なページ - 特定のアプリアクセス権限が必要 */}
          <Route path="/admin" element={
            <ProtectedRoute
              appName="portal"
              authenticationOnly={false}
              accessDeniedRedirectUrl="/access-denied"
            >
              <AdminPage />
            </ProtectedRoute>
          } />

          <Route path="/access-denied" element={<AccessDeniedPage />} />
        </Routes>
      </Router>
    </AuthProvider>
  );
}

このパターンが提供する柔軟性は以下の通りです:

メリット: - ページレベルの制御: 各ページで異なる認証・認可ポリシーを適用 - 段階的な認証: 公開ページ → 認証のみ → 認可必須という段階的なアクセス制御 - UXの最適化: 必要な時だけ認証を求めることで、ユーザー体験を向上 - カスタマイズ性: ページごとに異なるエラー画面やリダイレクト先を設定

API通信との統合

auth-kitは単独で機能するだけでなく、同じく内部パッケージとして開発したapi-clientと密接に連携します。これにより、認証トークンの取得からAPIリクエストへの付与まで、シームレスに処理されます:

// api-clientとの統合例
import { createAuthenticatedApiClient } from '@novasell/api-client';
import { useAuth } from '@novasell/auth-kit';

const useApiClient = () => {
  const { getAccessTokenSilently } = useAuth();

  return createAuthenticatedApiClient({
    baseURL: import.meta.env.VITE_API_URL,
    tokenProvider: getAccessTokenSilently,
  });
};

この統合により、開発者は認証トークンの管理を意識することなく、保護されたAPIエンドポイントにアクセスできます。トークンの更新も自動的に処理されるため、セッションタイムアウトの心配も不要です。

工夫点と技術的な配慮

1. 段階的な導入を可能にする2つのパターン

auth-kitの最大の工夫点は、開発者のニーズに応じて選べる2つの導入パターンを提供していることです。これにより、プロジェクトの規模や要件に関わらず、最適な方法で認証機能を実装できます。

AppAuthProviderパターン(シンプル): - わずか1つのコンポーネントでアプリ全体を保護 - 設定項目を最小限に抑え、デフォルト値で動作 - 新規プロジェクトや小規模アプリケーションに最適

AuthProvider + ProtectedRouteパターン(柔軟): - ページごとに認証・認可レベルを細かく制御 - 公開ページと保護ページの混在が可能 - 既存アプリケーションへの段階的な導入に最適

この2つのパターンにより、以下のメリットを実現しています:

  • 学習コストの最小化: シンプルなケースはシンプルに、複雑なケースは必要に応じて複雑に
  • 段階的な移行: 既存アプリケーションに少しずつ認証を追加可能
  • 柔軟な要件対応: プロジェクトの成長に合わせてパターンを変更可能

2. セキュアなURL検証

認証後のリダイレクト処理は、オープンリダイレクト脆弱性の温床となりやすい部分です。悪意のあるユーザーが、リダイレクト先URLを操作してフィッシングサイトに誘導する可能性があります。

auth-kitでは、ホワイトリスト方式でURLを検証し、安全性を確保しています:

// src/urlValidator.ts
export const getSafeReturnToUrl = (url: string): string => {
  try {
    const urlObj = new URL(url);

    // 許可されたオリジンのみにリダイレクト
    if (allowedOrigins.includes(urlObj.origin)) {
      return url;
    }

    // 不正なURLの場合はデフォルトに
    return getAllowedOrigin();
  } catch {
    return getAllowedOrigin();
  }
};

この実装により、事前に定義された信頼できるドメインへのリダイレクトのみが許可され、外部サイトへの意図しない遷移を防ぎます。

auth-kitが実現した価値

auth-kitパッケージの開発により、マルチフロントエンド環境での認証基盤を効率的に構築できました。

開発効率の向上

  • 迅速な認証導入: 新規アプリケーションでも数分で認証機能を実装可能
  • 学習コストの最小化: Auth0の詳細な知識がなくても適切な認証機能を導入
  • 統一された実装: チーム全体で一貫した認証パターンを使用

運用・保守性の向上

  • 一元管理: 認証ロジックの変更を全アプリケーションへ即座に反映
  • 設定の統一: 環境変数の一元管理により設定ミスを防止
  • 型安全性: TypeScriptによる型定義で実装時のエラーを早期発見

ユーザー体験の向上

  • シームレスなSSO: 複数のアプリケーション間でのスムーズな認証体験
  • 適切なリダイレクト: ログイン後、元々アクセスしようとしていたページへの自動遷移

今後の展望

自動セットアップツールの提供

現在は手動でコンポーネントをラップする必要がありますが、将来的にはCLIツールやコードジェネレーターを提供し、より簡単に認証機能を導入できるようにしたいと思っています:

# 将来のCLI
npx @novasell/auth-kit init --app-name "new-app"

このようなツールにより、ボイラープレートコードの生成や設定ファイルの自動生成が可能になり、さらに開発効率が向上すると考えています。

まとめ

マルチフロントエンド環境における認証・認可の複雑さは、適切な抽象化とパッケージ化により大幅に軽減できます。auth-kitパッケージの開発を通じて、以下の重要な教訓を得ました:

学びと教訓

  1. 初期からの共通化: それぞれで認証機能を実装してしまう前に共通パッケージ化することで、技術的負債の蓄積を防げる
  2. 開発者体験を重視: 2つの利用パターンを提供することで、段階的な導入や様々な要件に柔軟に対応できる
  3. 設定の統一管理: 設定を一箇所で管理することで、アプリケーション間での設定のズレを防止し、Auth0の設定変更時も一度の修正で全体に反映できる

マルチフロントエンド環境での認証基盤構築は決して簡単ではありませんが、適切なアプローチとツールを選択することで、開発効率とユーザー体験の両方を向上させることが可能です。
この記事が、同様の課題に直面している開発チームの参考になれば幸いです。

LLMによるOCR、画像→構造化データをOpenAI APIで実現【Image inputs × Structured Outputs】

こんにちは。 ノバセルにてデータサイエンティストをしています、石川ナディーム(@nadeemishikawa)です。

本記事ではOpenAI APIの「Image inputs対応モデル」「Structured Outputs」を組み合わせ、1 回の API 呼び出しで「画像 → 指定構造の構造化データ(JSON/Pydantic)」まで完結させる方法を紹介します。

はじめに

ビジネスシーンでは、請求書や名刺など、画像形式の非構造化データを扱う機会が、何かと数多く存在しています。今までは、これらの画像からテキスト情報を抽出するためにはOCR(光学的文字認識)が主に用いられてきましたが、いくつかの実用的な課題がありました。

今までのOCRも便利でしたが、例えば

  • ちょっとレイアウトが違うだけで、うまく読み取れない…
  • 読み取った後、欲しい情報だけを取り出すのが大変(正規表現つらい…)

こうした悩みが実務でよく発生していました。

しかし、最新のマルチモーダルモデルの登場で、こうした課題の多くが解消できるようになってきています。

本記事では、「LLMならではの柔軟な情報抽出」と、「構造化出力」をセットで使う実装ノウハウを、コードとともにご紹介します。

方法

ここからは、OpenAI APIの2つの機能を活用し、どのようにして画像から構造化データを抽出するのかを解説します。

1. Image inputs

Image inputに対応しているモデルは、画像をAPIの入力として渡せます。従来のテキストに加えて、画像も認識できるため、応用範囲がかなり広いです。

従来のOCRでは難しかった柔軟な解釈や、レイアウトに依存しないデータ抽出が実現できます。

たとえば

  • 「この写真に誰が写っている?」
  • 「この表の中身を要約して」

といった質問もできます。

画像はbase64エンコードするか、URLとすることで、APIリクエストに含めることができます。これにより、テキストと画像を組み合わせて指示を送ることができます。

2. Structured Outputs

Structured Outputsに対応しているモデルは、APIの返答を事前に定義したJSONスキーマやPydanticモデルの「型」に沿った構造化データで受け取ることができます。 従来の自由なテキスト出力だけでなく、明確に構造化された形での出力が得られるため、システムへの組み込みが簡単になります。

response_formatにJSON SchemaやPydanticで定義したモデルを指定することで簡単に利用することができます。

※Structured Outputsは、現在OpenAIの一部モデルにて利用可能です

実装例1:名刺から情報抽出

実装例として、まずは業務利用でも頻出の「名刺OCR」を題材に、名刺画像から記載情報を抽出し、構造化データとして出力するプロセスを解説します。

入力画像

異なるレイアウトの名刺画像(下図2枚)を用意しました。

(名刺画像はGeminiのCanvasを利用して、架空の名刺を生成したものです)

横長画像
画像A
縦長画像
画像B

コードとプロンプト設計

下記に示すPythonコードは、指定された名刺画像を読み込み、OpenAI APIを介して情報抽出を行う一連の処理を実装したものです。 コードにおいて重要な要素は、出力データ構造を定義するPydanticで定義したBusinessCardクラスと、モデルに入力するSYSTEM_PROMPTです。

コードの要点

  1. System Prompt に抽出過程を言語化し明確に指示

    ※プロンプトエンジニアリングについては本稿では、深く取り扱わないですが、公式が出している以下の記事がおすすめです。

    汎用的なもの platform.openai.com GPT-4.1でのプロンプトエンジニアリングについて、より深掘りしたもの cookbook.openai.com

  2. Structured Outputs関連

    1. pydanticを用いて、BusinessCardクラスを定義し、response_formatを指定
    2. 読み取れないフィールドはNoneとすることをプロンプト内で明確化、推測は禁止する
      1. 型指定で既に、Optional に設定しているが、このプロンプトを入れることで、読み取り確度が低い場合にハルシネーションを起こし、それっぽい値を出力をするという現象を一定防ぐ効果があります。
      2. また、読み取れなかった場合の挙動を明確にする意図もあります。空文字とNoneが入り混じってしまう等を防ぐ意味合いです。
    3. 複数項目がある場合は、コンマ区切りで文字列として読み取る(実運用ではリストで受け取ることを定義する場合もあります)
from pydantic import BaseModel
from typing import Optional
from openai import OpenAI

# 画像をbase64形式にエンコードする関数
def encode_image(image_path: str) -> str:
    with open(image_path, "rb") as image_file:
        return base64.b64encode(image_file.read()).decode('utf-8')

class BusinessCard(BaseModel):
    person_name_ja: Optional[str]
    person_name_en: Optional[str]
    job_title: Optional[str]
    company: Optional[str]
    email: Optional[str]
    phone_numbers: Optional[str]
    website: Optional[str]
    address: Optional[str]
    
# インプット画像へのパス
image_path = "path-to-your-image"

# APIに入力する画像をbase64形式にエンコード
base64_image = encode_image(image_path)

SYSTEM_PROMPT = """
# Role and Objective
あなたは名刺画像から情報を抽出する、OCRおよび情報抽出の専門エージェントです。  
与えられた名刺画像から必要な情報を構造化データとして正確に抽出することがあなたのミッションです。

# Instructions
- 名刺画像から抽出できる全ての情報を取得してください。
- 指定されたresponse_formatに完全に一致する結果を出力してください。
- **読み取れない、もしくは存在しないフィールドは必ずNoneにしてください**。
- キーの追加・削除・名前変更は禁止です。
- 読み取り項目の出力以外(コメント、説明文、マークダウン、推論過程など)は絶対に含めないでください。

## Sub-categories for more detailed instructions
- 氏名や役職など、明確に判別できるフィールドは可能な限り抽出し、正確に記述してください。
- 連絡先(メールアドレス、電話番号など)は、画像から読み取れる全てを対象にしてください。
- 英語・日本語表記が混在している場合は両方記載してください。
- 番号・URL・住所なども、可読な範囲で正確に記載してください。
- 複数のフィールドが存在する場合は、それぞれのフィールドをカンマで区切って文字列として出力してください。

# Reasoning Steps
1. 画像内の全情報を正確に読み取る。
2. response_formatに沿って各フィールドを正しく抽出する。
3. 抽出できなかった場合はNoneとする。
4. 必要な情報のみをresponse_format通りに出力する。

# Output Format
出力は以下の形式で行ってください。

## Example
{
  "person_name_ja": "山田太郎",
  "person_name_en": "Taro Yamada",
  "job_title": "営業部長",
  "company": "株式会社ABC, ABC Inc.",
  "email": "taro.yamada@example.com",
  "phone_numbers": ["03-1234-5678"],
  "website": "https://www.example.com",
  "address": "東京都千代田区1-2-3"
}

# Context
名刺には日本語・英語で記載された名前や会社名、複数の電話番号や連絡先、住所などが記載されています。
実際の画像内容に忠実に、抜けや漏れがないように情報を抽出してください。

# Final instructions and prompt to think step by step
- 出力前に、あなた自身の中で抽出した情報が要求に完全に合致しているかを必ず確認してください。
- 手順を内部で計画し、思考を整理してから最終出力を行ってください(ただし推論過程は絶対に出力しないこと)。
- 出力はresponse_formatに沿った形式で行ってください。
- 抽出できなかった場合は,Noneとしてください。推測で回答しないこと。
"""

USER_TEXT = "この名刺画像から全ての情報を抽出してください。読み取れない、存在しない項目はNoneにしてください。"
)

def extract(image_path: str):
    base64_image = encode_image(image_path)

    client = OpenAI()
    messages = [{
            "role": "system",
            "content": [
                {"type": "text", "text": SYSTEM_PROMPT},
            ]
        },
        {
            "role": "user",
            "content": [
                {"type": "text", "text": USER_TEXT},
                {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}}
            ]
        }
    ]
    response = client.beta.chat.completions.parse(
        model="gpt-4.1-2025-04-14",
        messages=messages,
        response_format=BusinessCard,
    )
    output = response.choices[0].message.parsed
    return output

result = extract(image_path)
for key, value in result.model_dump().items():
    print(f"{key}: {value}")

画像Aの結果:

person_name_ja: 楽刷 太郎
person_name_en: None
job_title: CXO
company: Novasell Inc., ノバセル株式会社
email: taro.raksul@xxx.com
phone_numbers: 03-1234-5678
website: https://novasell.com
address: 〒106-0041 東京都港区麻布台XXX

見事に、すべての要素が正しく認識されました!

画像Aにはperson_name_en(英語名)の記載がないため、出力結果ではNoneとなっています。 company(会社名)も日本語・英語どちらも正しく抽出され、カンマ区切りで抽出されています。

このように、プロンプトの制約部分も忠実に守られていることが確認できました。

画像Bの結果:

person_name_ja: 楽刷 太郎
person_name_en: Taro Raksul
job_title: CXO
company: 株式会社ノバセル, Novasell Inc.
email: taro.raksul@xxx.com
phone_numbers: 03-1234-5678
website: https://novasell.com
address: 東京都港区麻布台XXX

画像Aでも同様に、レイアウトが全く異なるにも関わらず、惑わされることなく、ちゃんと項目を理解し、同じ形式の正しいデータにしてくれています。

このレイアウトによらない、頑健性がこの方法の強みといえます。

応用編:路線図から「今いる駅」を読み取る

ここからは、さらに一歩進んだ応用例です。 「画像内の状態や関係性」といった、従来のOCRでは難しかった抽象的な概念抽出もLLMなら可能です。これはかなり応用範囲が広く、通常のOCRではできなかったことが可能となる大きな特徴の一つです。

応用例では、視覚的概念を読み取る例として、路線図写真から「現在いる駅名」を抽出するケースを扱います。

入力画像

異なる、レイアウトの路線図を用意しました。

横長画像
画像A
縦長画像
画像B

コードとプロンプト設計(一部抜粋)

class Station(BaseModel):
    station_name: Optional[str]

SYSTEM_PROMPT = """
# Role and Objective
あなたは駅案内画像から現在いる駅を出力する、専門のエージェントです。
現在いる駅名を出力することがあなたのミッションです。

# Instructions
- 画像内の情報から、現在いる駅名のみを特定してください。
- 現在いる駅は、案内図の中で「赤丸+赤字」で強調されています。この特徴をもとに判定してください。
- 出力は駅名(日本語)1つのみとしてください。
- 曖昧な場合は出力せず、Noneとしてください。
- 出力は駅名のみ。他のコメントや説明、形式、推論過程などは絶対に含めないでください。

# Reasoning Steps
1. 画像内の案内図から「赤丸+赤字」で表現されている駅を特定する。
2. その駅名のみを出力する。
3. 複数該当や不明な場合はNoneとする。

# Output Format
出力は以下の形式で行ってください。

## Example
{
  "station_name": "目黒駅"
}

# Final instructions and prompt to think step by step
- 出力前に、必ず上記条件に合致しているかを自身で確認してください。
- 出力は駅名1つのみ。それ以外は絶対に含めないこと。
"""

USER_TEXT = "この画像から現在いる駅名を出力してください。"

def extract(image_path: str):
    base64_image = encode_image(image_path)

    client = OpenAI()
    messages = [
        {
            "role": "system",
            "content": [
                {"type": "text", "text": SYSTEM_PROMPT},
            ]
        },
        {
            "role": "user",
            "content": [
                {"type": "text", "text": USER_TEXT},
                {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}}
            ]
        }
    ]
    response = client.beta.chat.completions.parse(
        model="gpt-4.1-2025-04-14",
        messages=messages,
        response_format=Station,
    )
    output = response.choices[0].message.parsed
    
    return output

result = extract(image_path)
print(result.station_name)

画像A・Bの結果:

神谷町

画像A・Bでは、全くレイアウトや情報量の違うものであるにも関わらず、両方で期待した出力を得ることができました。

従来のOCR技術では、画像内の全駅名をリスト化することは可能であっても、「現在地」を示す記号と特定の駅名とを関連付けて認識することは極めて困難でした。

しかし、マルチモーダルモデルの発展により抽象的な指示に基づく情報抽出が実現できます。応用例は一要素で簡単なものでしたが、「路線名」「現在駅」「次駅」といったスキーマを定義して、Structured Outputsを用いることで、モデルは画像内の要素間の関係性を解釈し構造化されたデータを返すことも可能です。

実務におけるプロンプト設計のコツ

先に共有した公式のプロンプトエンジニアリングの記事に加えて、良いプロンプトを作成するためには、「自分がどのように情報を認識しているのか」を明確に言語化することが不可欠です。 特に、以下がプロンプトを作成する上で大事だと考えています。

  • 普段自分が無意識に行っている認識や判断のプロセスを洗い出し、それをプロンプトの指示に具体的に反映すること
  • 無意識に行っている動作や判断であっても、ひとつひとつを明確な言葉として表現し、モデルに指示を与えること

この「言語化」の能力がプロンプト設計において非常に重要となります。無意識的に行っている認識プロセス・業務プロセスを言語化することで、モデルが画像や情報を適切に解釈するようになり、精度の高い出力を得られるようになります。

まとめ

ここまでお読みいただきありがとうございました! LLM×画像認識の組み合わせは、これまで手間だった一連の工程をワンステップで自動化できます。以下が、本記事のポイントです。

  • 従来のOCRの限界を突破: レイアウトや記載内容の違いにも強く、柔軟に情報を取得可能
  • 即・構造化データ化: 1API呼び出しで欲しい形式にそのまま変換
  • 業務にあわせて自由に拡張可能: JSONやPydantic形式をカスタマイズして使える
  • 文脈・関係性の理解も得意: 画像内の意味・関係まで抽出できる

名刺や帳票、地図、UIキャプチャ、TVCMや広告クリエイティブの要素抽出など、定型・非定型を問わず多くの業務で活用可能です。 使い方しだいで応用方法は無限大かと思います、ぜひ一度お試しください!

チーム開発を加速するAI駆動型dbtワークフローの実践

はじめまして。バックエンドエンジニアの渡邉です。普段は、データエンジニアリングチームの一員として、dbtを使った開発ワークフローの自動化や効率化を支援しています。 この度、私たちのチームが実践しているAIと人間が協働する新しい開発スタイルについて、筆を執らせていただくことになりました。 本記事が、皆さんのチームの開発体験を向上させる一助となれば幸いです。

はじめに:なぜAI駆動型ワークフローが必要か

データエンジニアリングの現場では、dbtを使ったデータモデリングが主流になってきました。しかし、チーム開発において以下のような課題を感じることはないでしょうか。

  • 新しいモデルを作るたびに、命名規則やコーディングスタイルにばらつきが生じる
  • テストを書くのが後回しになり、設計の検証や改善の機会を失う
  • レビューに時間がかかり、開発のボトルネックになる
  • ジュニアメンバーの立ち上がりに時間がかかる

これらの課題を解決するため、私たちはCursorのProject Rules機能を活用することにしました。

CursorのProject Rulesとは?

CursorのProject Rulesは、AIエージェントの振る舞いを制御するための、再利用可能でスコープが限定された指示セットです。具体的には、プロジェクトのルートや特定のディレクトリに.cursor/rulesというフォルダを作成し、その中にルールを記述したファイル(.mdc形式)を配置します。

これにより、以下のようなことが可能になります。

  • ドメイン知識の共有: プロジェクト固有の知識(命名規則、アーキテクチャ方針など)をルールとしてAIに教え込む。
  • ワークフローの自動化: 定型的な作業(例:Boilerplateの生成、テストケースの追加)をAIに任せる。
  • 品質の標準化: コーディングスタイルや設計原則を強制し、チーム全体の成果物の品質を一定に保つ。

私たちはこの機能を活用し、AIと人間が協働する新しいdbt開発ワークフローを構築しました。本記事では、その実践内容と効果について紹介します。

AI駆動型ワークフローの全体像

私たちが構築したワークフローは、人間主導の設計・レビューと、2つのルールによる自動化を組み合わせた4つのフェーズから構成されています。

graph LR
    A["Phase 1<br/>設計<br/>(人間主導)"] --> B["Phase 2<br/>Staging自動生成"]
    B --> C["Phase 3<br/>TDDで設計・実装"]
    C --> D["Phase 4<br/>チームレビュー<br/>(人間主導)"]

    style A fill:#e1f5f3
    style B fill:#fff3cd
    style C fill:#fff3cd
    style D fill:#cfe2ff

設計・レビューは人間が主導し、実装フェーズでAIと人間が協働することで、良い設計と開発速度の両立を目指しています。

私たちの技術スタック

このワークフローは、以下のツールを組み合わせて実現しています:

  • データ統合: Fivetran(外部データソースからの自動取り込み)
  • データウェアハウス: Snowflake / BigQuery
  • データ変換: dbt(データモデリングとテスト)
  • 開発環境: Cursor(AIペアプログラミング)
  • MCPサーバー: DataPilot(VSCode拡張のPower User for dbtに付属)
  • 品質管理: SQLFluff(コードスタイルチェック)、dbt_project_evaluator(プロジェクト構造の自動評価)

Phase 1: 設計:今後のAI活用への期待

最初のフェーズである設計は、現在も人間が主導で行っています。ビジネス要求からテクニカルな仕様への変換は、経験豊富なエンジニアの暗黙知に依存しており、属人性が高い状況です。

現状では、「売上データの日次集計モデルを作りたい」という要求に対して、人間のエンジニアが以下のような質問で要件を明確化しています:

  • 集計の粒度は?(日次×商品?日次×店舗?)
  • 売上の定義は?(受注ベース?出荷ベース?)
  • 必要なディメンションは?(地域、カテゴリ、顧客属性など)

このような要件明確化のプロセスも、今後はルールとしてAIに実行させることで、設計品質の標準化を図っていく予定です。

Phase 2: Stagingモデルの自動生成で単純作業から解放

ソーステーブルからstagingモデルを作成する作業は、定型的ながら意外と時間がかかります。私たちのプロジェクトでは、Fivetranを使って様々な外部データソースからデータを取り込んでいますが、ここで一つの課題に直面していました。

特に課題となっていたのが、FivetranのGoogle Sheets Connectorの挙動です。私たちのチームでは、ビジネス部門が管理するGoogleスプレッドシートも重要なデータソースとして活用しています。これらのシートでは、管理のしやすさから「注文ID」や「顧客名」といった日本語でカラム名が付けられていることが少なくありません。

しかし、Fivetranがこれらの日本語カラムを持つテーブルを同期する際に、日本語の漢字が意図せず中国語のローマ字(ピンイン)のように変換されてしまうケースがありました。例えば、「注文ID」というカラムがDING_DAN_IDのような、元の意味を推測しにくい文字列になってしまうのです。

この結果、以下のような作業が機械的でありながら、ミスが起きやすく、開発者の負担となっていました。

  • 列名の解読と標準化(中国語ローマ字→英語のスネークケース変換、略語の展開)
  • データ型の適切な設定
  • 不要なメタデータカラムの除外(Fivetranが付与する同期情報など)
  • ドキュメントの記述

dbt-stgルールを使うことで、これらの作業が自動化されます。

実際の動作例

例えば、以下のようなソーステーブルがあったとします:

  • ソーステーブル: ORDER_TRANSACTIONS
  • カラム:
    • DING_DAN_ID (VARCHAR) -- 订单ID
    • KE_HU_MING (VARCHAR) -- 客户名
    • JIN_E (NUMBER) -- 金额
    • RI_QI (VARCHAR) -- 日期
    • _FIVETRAN_SYNCED (TIMESTAMP)
    • _FILE (VARCHAR)
    • _LINE (NUMBER)

AIに対して「SOURCE_ECOMMERCE.MAINのORDER_TRANSACTIONSテーブルに対してstgモデルを作成してください」と指示すると、以下のようなファイルが自動生成されます:

models/staging/ecommerce/source.yml

version: 2

sources:
  - name: ecommerce_source
    database: SOURCE_ECOMMERCE
    schema: MAIN
    tables:
      - name: order_transactions
        columns:
          - name: DING_DAN_ID
            description: '订单ID - 注文ID'
          - name: KE_HU_MING
            description: '客户名 - 顧客名'
          - name: JIN_E
            description: '金额 - 金額'
          - name: RI_QI
            description: '日期 - 日付'

models/staging/ecommerce/stg_ecommerce__order_transactions.sql

with
    source as (
        select
            DING_DAN_ID,
            KE_HU_MING,
            JIN_E,
            RI_QI
        from {{ source('ecommerce_source', 'order_transactions') }}
    ),
    cleaned as (
        select
            ding_dan_id as order_id,           -- 订单ID → order_id
            ke_hu_ming as customer_name,       -- 客户名 → customer_name
            jin_e::number as amount,           -- 金额 → amount
            ri_qi::date as order_date          -- 日期 → order_date
        from source
    )
select * from cleaned

models/staging/ecommerce/stg_ecommerce__order_transactions.yml

version: 2

models:
  - name: stg_ecommerce__order_transactions
    description: 'Eコマースの注文トランザクションデータ'
    columns:
      - name: order_id
        description: '注文ID'
        data_type: VARCHAR
        data_tests:
          - unique
          - not_null
      - name: customer_name
        description: '顧客名'
        data_type: VARCHAR
      - name: amount
        description: '注文金額'
        data_type: NUMBER
        data_tests:
          - not_null
      - name: order_date
        description: '注文日'
        data_type: DATE
        data_tests:
          - not_null

このように、中国語ローマ字の列名を適切な英語名に変換し、Fivetranのメタデータを除外し、適切なデータ型とテストまで設定してくれます。

Phase 3: テスト駆動開発(TDD)で設計を進化させる

stagingモデルの次は、ビジネスロジックを含むmartモデルの実装です。ここで重要なのが、テスト駆動開発(TDD)のアプローチです。

TDDは単に「テストを先に書く」だけの手法ではありません。Kent Beck氏の定義によれば、TDDは小さなステップで設計を進化させていくプロセスです。テストは設計の副産物として得られ、将来の変更に対する安全網となります。

dbtルールは、必ず以下のサイクルを守るように設計されています:

  1. Red(失敗するテストを書く) - まだ存在しない振る舞いを定義
  2. Green(テストをパスさせる) - 最小限の実装で動作させる
  3. Refactor(設計を改善する) - テストを通したまま内部設計を洗練

TDDサイクルの実例

ここでは、私たちの実際の業務を単純化した「日次の売上サマリーモデルを作成する」という架空の要求を例に、AIがTDDサイクルに沿って実装を進める様子を解説します。

Step 1: Red - 失敗するテストを書く

unit_tests:
  - name: test_daily_sales_summary_aggregation
    model: mrt_daily_sales_summary
    description: '日次×店舗別に売上が正しく集計されることをテスト'
    given:
      - input: ref('stg_ecommerce__order_transactions')
        rows:
          - { order_date: '2024-01-01', store_id: 'STORE_A', amount: 1000 }
          - { order_date: '2024-01-01', store_id: 'STORE_A', amount: 500 }
          - { order_date: '2024-01-01', store_id: 'STORE_B', amount: 2000 }
          - { order_date: '2024-01-02', store_id: 'STORE_A', amount: 1500 }
    expect:
      rows:
        - { sales_date: '2024-01-01', store_id: 'STORE_A', total_sales: 1500, order_count: 2 }
        - { sales_date: '2024-01-01', store_id: 'STORE_B', total_sales: 2000, order_count: 1 }
        - { sales_date: '2024-01-02', store_id: 'STORE_A', total_sales: 1500, order_count: 1 }

この時点でdbt buildを実行すると、モデルがまだ存在しないため、テストは失敗します。

Step 2: Green - テストをパスさせる

次に、テストをパスさせるための最小限のコードを実装します:

select
    order_date as sales_date,
    store_id,
    sum(amount) as total_sales,
    count(*) as order_count
from {{ ref('stg_ecommerce__order_transactions') }}
group by 1, 2

Step 3: Refactor - コードを改善する

テストがパスしたら、コードの品質を向上させます:

with
    daily_aggregation as (
        select
            order_date as sales_date,
            store_id,
            sum(amount) as total_sales,
            count(distinct order_id) as order_count,
            avg(amount) as average_order_value
        from {{ ref('stg_ecommerce__order_transactions') }}
        where order_date is not null
            and store_id is not null
        group by 1, 2
    )
select
    sales_date,
    store_id,
    total_sales,
    order_count,
    average_order_value
from daily_aggregation

このTDDサイクルにより、以下のような効果が得られます:

  • 設計の進化: 必要最小限の実装から始めて、徐々に洗練された設計へと進化させる
  • 仕様の明確化: テストを書く過程で、曖昧な要求が具体的な振る舞いとして定義される
  • 変更への安心感: 自動テストがセーフティネットとなり、リファクタリングを恐れずに実施できる

TDDは「品質保証」の手法というよりも、「設計手法」として捉えることが重要です。品質の向上は、良い設計の結果として自然に得られる副次的な効果なのです。

Phase 4: チームレビュー:AIによる設計と実装の接続へ

TDDサイクルによって技術的な品質が担保されたモデルも、最終的にはチームによるレビューが必要です。このフェーズの理想は、Phase 1で定義された設計と、Phase 3で実装されたコードが完全に一致していることを、効率的かつ客観的に検証することです。

現状、私たちのレビュープロセスは、dbt_project_evaluatorによる機械的なチェックと、人間による本質的なチェックの二段階で構成されています。dbt_project_evaluatorは、命名規則やテスト・ドキュメントのカバレッジなど、定義可能なベストプラクティスを網羅的にチェックしてくれる強力なツールです。

しかし、このアプローチにも課題は残されています。 「実装されたビジネスロジックが、本当に設計の意図を反映しているか?」という最も重要な検証が、レビュアーの経験やコンテキスト理解度に依存し、属人化してしまっているのです。

この課題を解決するため、私たちはPhase 1の成果物(設計情報)をインプットとしてAIに与え、ビジネスロジックの妥当性まで踏み込んでレビューを行う、新しいルールの導入を計画しています。これにより、設計と実装の間の見えない乖離をAIが検出し、人間はより戦略的な判断に集中できるようになります。

人間が最終的に注力すべきレビュー観点

AIレビューが導入された後、人間のレビュアーは以下の様な、より高度で戦略的な観点に集中することになります。

  1. ビジネスインパクトの評価
    • このモデル変更がビジネス全体に与える影響は何か
    • 隠れた前提条件や、ビジネス上のリスクはないか
  2. チーム横断でのコンテキスト
    • 他チームのプロジェクトとの依存関係や整合性
    • プロジェクト全体のロードマップとの整合性
  3. 未来への投資
    • 長期的なアーキテクチャの方向性との一致
    • 技術的負債の許容範囲と返済計画
    • パフォーマンスとコストの最適なバランス

従来のレビューで時間を取られていた機械的なチェックやロジックの逐次的な確認はAIとツールに任せ、人間は「最終承認者」として、より大局的な視点からの判断に責任を持つことになります。

導入による変化:体感的な効果

このワークフローを導入してから、チームの開発体験は大きく変わりました。具体的な変化を紹介します。

開発体験の向上

自動生成による効果

  • 「またstagingモデルを作るのか...」という憂鬱な気持ちがなくなった
  • 命名規則で悩む時間がゼロになった
  • チーム全体でコードの一貫性が保たれるようになった

TDD実装による効果

  • 小さなステップで設計を進化させることで、複雑な問題も扱いやすくなった
  • リファクタリングを恐れなくなり、より良い設計を追求できるようになった
  • テストを書く過程で仕様の曖昧さを早期に発見し、明確化できるようになった
  • 「動くコード」から「良い設計のコード」へと段階的に改善する習慣が身についた

AIレビューによる効果

  • 基本的なミスによる手戻りがほぼなくなった
  • レビュー前の自己チェックの負担が大幅に減った
  • ベストプラクティスを自然に学べるようになった

人によるレビューの質的向上

  • 「ここのビジネスロジック、こういうケースはどう扱う?」といった本質的な議論が増えた
  • レビューが学びの場になった
  • チーム全体のスキルレベルが向上した

チーム全体への影響

特に印象的だったのは、ジュニアメンバーの成長速度です。AIのサポートにより、経験が浅いメンバーでも最初から一定品質のコードを書けるようになりました。また、AIレビューのフィードバックを通じて、ベストプラクティスを実践的に学べるようになっています。

シニアメンバーにとっても、機械的なレビューから解放され、アーキテクチャ設計やビジネス要件の深掘りなど、より価値の高い活動に時間を使えるようになりました。

ルールの紹介(簡易版)

以下に、実際に使用しているルールを一部簡略化して紹介します。

dbt-stg.mdc

あなたは開発の権限を委任されたエージェント。許可したコマンドは確認なしで実行して、指示されたタスクを自律的に完遂してください。指示された範囲のタスクを自律的に完遂することに全力を挙げてください。指示内容の再確認、実行できるコマンドは依頼者に確認することは禁止。以下の指示に従って、効率的かつ正確にタスクを遂行してください。

## 概要

ソーステーブルからdbt staging modelを完全自動で生成します。`@etl/dbt.mdc`のTDD手法と併用してください。

## 使用方法

~~~入力テンプレート
{データベース名}.{スキーマ名}の以下テーブルに対してstgモデルを作成してください。
{テーブル名パターン or テーブル名を列挙}
~~~

例:

~~~入力例1
SOURCE_NA4D.MAIN_novasellの以下テーブルに対してstgモデルを作成してください。
dris_プレフィックスを持つテーブルj
~~~

~~~入力例2
SOURCE_NA4D.MAIN_novasellの以下テーブルに対してstgモデルを作成してください。
- dris_base_and_sales_company_mappings
- dris_conversions
- dris_internal_heim_cv_mappings
~~~

---

## 自動実行フロー

### Phase 1: 環境確認と情報収集

1. **プロジェクト情報取得**
   - `mcp_datapilot_get_projects`で現在のdbtプロジェクトを特定

2. **対象テーブル特定**
   - `SHOW TABLES IN {指定されたスキーマ}`で対象テーブルを抽出
   - 各テーブルの列情報を`DESCRIBE TABLE`で取得

3. **既存ファイル確認**
   - `models/staging/{データソース名}/`ディレクトリの存在確認
   - 既存のsource.ymlファイルの確認

### Phase 2: 列マッピング自動生成

1. **中国語ローマ字→英語変換の例**

   以下は単なる例なので、その他のパターンにも対応してください。**列名が中国語ローマ字でない場合、このルールは無視してください。**

   - FAN_SHE → sales_company (販社)
   - DAN_DANG → responsible/assigned (担当)
   - JU_DIAN → base/location (拠点)
   - FEN_LEI → category/classification (分類)
   - KATEGORI → category (カテゴリ)
   - RI/RI_QI → date (日/日期)
   - SHI_JIAN → time/datetime (時間)
   - MING → name (名)
   - HAO/ID → id/number (号/ID)
   - SHU → count/number (数)
   - JIN_E → amount (金額)
   - YU_BEI → reserved/spare (予備)
   - XIANG_MU → item/project (項目)
   - QI_HUA → plan/campaign (企画)
   - ZHONG_BIE → type/kind (種別)
   - DOU_DAO_FU_XIAN → prefecture (都道府県)

2. **型変換検出**
   - 日時パターン: `_RI`, `_DATE`, `_TIME`, `_TIMESTAMP` → DATE/TIMESTAMP型へ
   - 数値パターン: `_ID`, `_COUNT`, `_AMOUNT`, `_SHU` → NUMBER型へ
   - その他: VARCHAR型(デフォルト)

3. **不要列の自動除外**

   以下はFivetranメタデータ列です。不要なので除外してください。

   - `_FILE`
   - `_LINE`
   - `_SHEET_NAME`
   - `_MODIFIED`
   - `_FIVETRAN_SYNCED`
   - `_DELETED`

### Phase 3: ファイル自動生成

#### 3.1 source.yml生成

~~~yaml
version: 2

sources:
  - name: {データソース名}_source
    database: SOURCE_NA4D
    schema: {スキーマ名}
    tables:
      - name: {テーブル名}
        columns:
          - name: {列名}
            description: '{自動推測された説明}'
~~~

#### 3.2 SQLモデル生成(CTE構造)

~~~sql
with
    source as (
        select
            {必要な列のみを明示的に選択}
        from {{ source('{source_name}', '{table_name}') }}
    )
    , cleaned as (
        select
            {中国語列} as {英語列名},
            {日時列}::date as {列名},
            {数値列}::number as {列名}
        from source
    , final as (
        select
            {最終的に必要な列のリスト}
        from {直前のCTE}
    )
select * from cleaned
~~~

#### 3.3 YMLファイル生成

- モデル定義とdescription
- 列定義とdata_type
- unit_testsの雛形
- data_testsの自動設定

### Phase 4: 品質保証

1. **命名規則の適用**
   - モデル名: `stg_{データソース名}__{テーブル名}`
   - 列名: snake_case英語名

2. **ディレクトリ構造**

   ~~~dir-structure
   models/staging/{データソース名}/
   ├── source.yml
   ├── stg_{データソース名}__{テーブル名}.sql
   └── stg_{データソース名}__{テーブル名}.yml
   ~~~

---

## 注意事項

- **@etl/dbt.mdcとの併用**: TDDサイクルの実行は`@etl/dbt.mdc`の手順に従ってください
- **中国語ローマ字→英語変換に自信がない場合**: なるべく質問してください

dbt.mdc

あなたは開発の権限を委任されたエージェント。許可したコマンドは確認なしで実行して、指示されたタスクを自律的に完遂してください。指示された範囲のタスクを自律的に完遂することに全力を挙げてください。指示内容の再確認、実行できるコマンドは依頼者に確認することは禁止。以下の指示に従って、効率的かつ正確にタスクを遂行してください。

## 目的

追加・変更する **dbt モデル**に対し、テスト駆動開発(TDD)を実践することで、設計を段階的に進化させながら実装を進める。具体的には、まず失敗するテストを書いて振る舞いを定義し、そのテストをパスする最小限のコードを実装し、その後コードをリファクタリングして設計を改善するサイクルを繰り返す。このプロセスを通じて、良い設計と自動テストという2つの成果物を同時に得る。

---

## 事前準備

1. **モデルを追加する場合のみ、モデル名を提案してユーザーに確認する**
   * モデル変更の場合、「2. **dbtのドキュメントを確認する**」以降を実行する。
   * モデルを追加する場合、適切なモデル名を提案する。
   * **ユーザーの応答を待つ。**
   * ユーザーがユーザーが提案されたモデル名を承認したら、その名前を使用して以降の作業を実施する。
   * ユーザーが承認しなかった場合は、再度モデル名を提案するか、ユーザーの指示を待つ。
2. **dbtのドキュメントを確認する**
   * 作業前に、必ずDocsの @dbt を確認する。
   * 特に、 Best practice guides および Unit Testing のセクションについて読み込む。
   * 他に、ユーザの指示に関連する項目について読み込む。
   * **このステップの完了を明示的に報告すること。**
     * 具体例
       ```markdown
       DBTドキュメントの確認が完了しました。特に以下の項目について理解を深めました。
       - dbtのベストプラクティス
       - dbtのユニットテストの記述方法
       - {{ その他、理解した主要項目を列挙 }}
       ```

---

## テスト駆動開発(TDD)サイクル

以下の「レッド」「グリーン」「リファクタリング」のステップを、実装すべき各機能や変更点に対して繰り返すこと。

### 1. レッド:失敗するテストを書く

* **a. 要求の理解**: ユーザーからの指示に基づき、これから実装または変更するモデルの振る舞いやロジックを明確に理解する。
    * 理解できない場合、**ユニットテスト記述前に**、質問する。
* **b. ユニットテストの記述**:
    * 理解した要求を検証するための **`unit_test`** を、対象モデルに対応する `*.yml` ファイルのルート(`models`と同階層)に記述する。
    * このテストは、現時点では**必ず失敗する**ように書くこと(つまり、まだ実装されていないロジックや、変更前のロジックではパスしないテストを意図的に作る)。
    * テストケースは、正常系だけでなく、代表的な異常系や境界値も考慮すること。
    * 既存モデルの変更の場合、既存の`unit_tests`が新しい要求に適合しなくなる場合は、まずそのテストを修正・更新する(これが失敗するテストとなる)。不足しているテストがあれば追加する。
* **c. `dbt build` の実行と失敗確認**:
    * `datapilot の MCP ツール` を使用して `dbt build --select <対象モデル名>` を実行する。
    * 記述した `unit_test` が意図通りに **FAIL** することを必ず確認する。もしPASSしてしまう場合は、テストケースが不十分か、既に実装済みである可能性があるので、テストを見直す。

### 2. グリーン:テストをパスさせる

* **a. 最小限のコード実装**:
    * ステップ1で失敗した `unit_test` を **PASSさせるためだけの最小限のコード**を、対象のdbtモデル(`.sql`ファイル)に記述または修正する。
    * ここでは、完璧なコードを目指すのではなく、テストをグリーンにすることだけを目的とする。
* **b. `dbt build` の実行と成功確認**:
    * `datapilot の MCP ツール` を使用して `dbt build --select <対象モデル名>` を実行する。
    * 該当の `unit_test` が **PASS** することを必ず確認する。
    * もし他のテストが意図せずFAILした場合は、影響範囲を特定し修正する。すべてのユニットテストがPASSするまでこのステップを繰り返す。

### 3. リファクタリング:設計を改善する

* **a. モデルコードのリファクタリング**:
    * ステップ2で書いたモデルコード(`.sql`)を見直し、設計の観点から改善する。
    * 可読性、保守性、パフォーマンスを考慮しながら、より良い設計へと進化させる。
    * ロジックの重複排除、変数名やCTEの命名規則遵守、複雑なロジックの簡略化などを行う。
    * **SQL のインラインコメント**: 冗長で無意味なインラインコメントは追加しない。**既存のインラインコメントは、削除しないこと。**
* **b. データテスト(`data_tests`)の追加・更新**:
    * モデルの出力データの品質を担保するため、`*.yml` ファイルのモデル定義またはカラム定義に **`data_tests`** を追加・更新する。
    * 追加する制約は、以下の情報を総合的に判断して設定する。
        * **参照テーブルのスキーマ定義(`.yml`ファイル)の確認**:
            * 当該モデルが参照している他のテーブル(ソース、ステージング、中間モデルなど)の `.yml` ファイルを確認する。
            * それらのファイルに定義されている `data_tests`(特に `unique`, `not_null_multiple_columns`, `relationships` など)を把握する。
            * 新しく作成または変更するモデルのデータテストを記述する際に、これらの既存の制約との整合性を考慮し、必要に応じて同様の制約を継承または適用する。
        * **実データの確認**: `datapilot MCP ツール(execute_sql)` で **参照テーブルをクエリ** し、実データ(データそのものや、DESCRIBE TABLE等で得られるスキーマ情報を含む)を確認したうえで、制約の妥当性を判断する。
    * `tests:` キーの使用は禁止。必ず **`data_tests:`** を用いる。
    * **NOT NULL 制約**: 独自マクロである **`not_null_multiple_columns`** を用いて記述する。標準の `not_null` テストは使用しない。
    * **ユニーク制約**:
        * **単一カラム**のユニーク制約には、標準の **`unique`** テストを用いる。
        * **複数カラムの組み合わせ(複合キー)**によるユニーク制約には **`dbt_utils.unique_combination_of_columns`** を用いる。
    * その他、**参照整合性制約** (`relationships`)などを中心に追加する。
* **c. データタイプ(`data_type`)の追加・更新**
    * 上記 **b. データテスト(`data_tests`)の追加・更新** のために確認した以下の情報を元に、各列に `data_type` を設定する。
        * 参照テーブルの `.yml` ファイルに記述された既存の `data_type`。
        * `datapilot MCP ツール(execute_sql)` で参照テーブルを直接クエリして得られた実際のカラムの型情報(例: `DESCRIBE TABLE` コマンドの結果など)。
    * 型の表現は、参照している他のテーブルの `.yml` ファイルや、プロジェクト内の既存モデルの記述に合わせ、一貫性を保つこと。もし参照先に `data_type` の記述がない場合は、クエリで得られた実際の型情報に基づいて適切なものを選択する。
* **d. ユニットテストのリファクタリング**:
    * 既存の `unit_tests` が冗長であったり、改善の余地がある場合は修正する。
    * 必要に応じて、カバレッジ向上のために `unit_tests` を追加する。
* **e. `dbt build` の再実行と全テストPASS確認**:
    * `datapilot の MCP ツール` を使用して `dbt build --select <対象モデル名>` を実行し、全ての `unit_tests` および `data_tests` が **PASS** することを確認する。
* **f. `sqlfluff lint` の実行と修正**:
    * 以下のコマンドを実行し、SQLのスタイルガイドに従っているか確認する。
        ~~~bash
        sqlfluff lint <変更したモデルの.sqlファイルパス>
        ~~~
    * すべてのエラーおよび警告が解消されるまで、修正と再実行を繰り返す。

---

## 実装完了の判断基準

* ユーザーから指示された全ての機能追加・変更が完了している。
* 全ての機能・変更点に対して上記のTDDサイクル(レッド・グリーン・リファクタリング)が実施されている。
* 最終的な `dbt build` で、対象モデルに関連する全てのテスト(`unit_tests``data_tests`)が **PASS** する。
* 最終的な `sqlfluff lint` で、エラーが **0件** である。

---

## 禁止事項

* **TDDサイクルを無視した実装**: 必ず「失敗するテストを先に書く」ことから始めること。
* **SQL のインラインコメントの不適切な使用**:
    * 冗長で無意味なインラインコメントを新規に追加しない。
    * **既存のインラインコメントは、削除しないこと。**
* `tests:` キーの使用(必ず `data_tests:` を使うこと)

---

## ymlに記述するテストの具体例

~~~yml
version: 2

models:
  - name: stg_searchconsole__search_term_performances
    description: 'Google Search Console の日別・検索語句別パフォーマンス集計'
    data_tests:
      - dbt_utils.unique_combination_of_columns:
          combination_of_columns:
            - date
            - search_term
      - not_null_multiple_columns:
          columns:
            - date
            - search_term
            - impressions
            - clicks
            - data_source_id
    columns:
      - name: date
        description: '日付'
        data_type: DATE
      - name: search_term
        description: '検索語句'
        data_type: VARCHAR
      - name: impressions
        description: '表示回数'
        data_type: NUMBER
        data_tests:
          - dbt_utils.expression_is_true:
              expression: '>= 0'
      - name: clicks
        description: 'クリック数'
        data_type: NUMBER
        data_tests:
          - dbt_utils.expression_is_true:
              expression: '>= 0'
      - name: data_source_id
        description: 'データソースID'
        data_type: VARCHAR

unit_tests:
  - name: test_stg_searchconsole__search_term_performances_aggregation
    model: stg_searchconsole__search_term_performances
    description: '日別・検索語句別に集計され、data_source_idが正しく設定されることをテスト'
    given:
      - input: source('novasell_source', 'google_search_console')
        rows:
          - { DATE: '2023-01-01', QUERY: 'term1', CLICKS: 10, IMPRESSIONS: 100 }
          - { DATE: '2023-01-01', QUERY: 'term1', CLICKS: 5, IMPRESSIONS: 50 }
          - { DATE: '2023-01-01', QUERY: 'term2', CLICKS: 20, IMPRESSIONS: 200 }
          - { DATE: '2023-01-02', QUERY: 'term1', CLICKS: 1, IMPRESSIONS: 10 }
    expect:
      rows:
        - {
            date: '2023-01-01',
            search_term: 'term1',
            clicks: 15,
            impressions: 150,
            data_source_id: 'novasell_gsc_search_term',
          }
        - {
            date: '2023-01-01',
            search_term: 'term2',
            clicks: 20,
            impressions: 200,
            data_source_id: 'novasell_gsc_search_term',
          }
        - {
            date: '2023-01-02',
            search_term: 'term1',
            clicks: 1,
            impressions: 10,
            data_source_id: 'novasell_gsc_search_term',
          }
~~~

今後の展望

現在は2つのルールを使っていますが、今後は以下のような拡張を考えています:

  • dbt-design: 設計フェーズでの要件明確化支援
  • dbt-review: 設計情報を基にビジネスロジックの妥当性を検証するレビュー支援
  • dbt-test: より高度なテストケースの自動生成
  • dbt-docs: ドキュメントの自動生成と更新
  • dbt-perf: パフォーマンスチューニングの提案

また、他のdbt以外のツールを使った開発(Dagsterなど)への展開も視野に入れています。

謝辞

本記事を執筆するにあたり、多くの方からインスピレーションをいただきました。この場をお借りして感謝申し上げます。

AIエンジニアのkinopeeさんには、AIとの協働に関する数々の先進的な取り組みから、本記事の着想を得る上で多大な影響を受けました。kinopeeさんの存在がなければ、この記事が形になることはありませんでした。

また、dbt.mdcルールの中核をなすTDDサイクルは、和田卓人(@t_wada)さんによる「【翻訳】テスト駆動開発の定義」から大きな着想を得ています。この記事のおかげでTDDの本質を学ぶことができました。素晴らしい記事を翻訳し、公開してくださったことに心から感謝いたします。

この記事が、皆さんの開発の一助となれば幸いです。

データサイエンティストとして求められてきた事

こんにちは。ノバセルでデータサイエンティストをしている石井です。
本記事では、「データサイエンティストとして求められてきた事」という側面に対し、抽象度の高い整理を試みています。
構成は以下の通りです。

はじめに

データサイエンティストと一重に言えても、その解釈は多面的です。
同じ業界であっても各組織で異なる定義を持ち得、ある組織内であっても各個人の対する仕事は異なり得ます。

本記事では、「分析対象のドメイン知識」と「定量データ」を駆使して事業成長に貢献する人材を、狭義の “データサイエンティスト” と定義し、以下では単に「分析者」と呼びます。

本論

1.求められてきた事

多くは、一言で「リスク管理」だと考えています。
データに基づいた分析結果に対し、100 %の再現性を持つと言えることはそうないでしょう。それどころか、1 変数の振る舞いの変化が再現性の瓦解に繋がることすら少なくないでしょう。
にも関わらず分析を実施するモチベーションは、「暗中模索や定性的な判断による失敗のリスクを取るより、一定度のコスト*1を使ってでも、効率的であり成功を期待できる施策に繋げる方が、事業成長の期待値を高く見積もれる」ことではないかと解釈しています。
100 %の再現性はなくとも、70 %成功することに納得できる施策が明らかになれば、十分に意味があると。そしてその分析がスピーディーに遂行されるのであれば尚良いと。

以下は、分析が「求められた内容を満たせる(リスク管理できている)」結果であるかを、依頼者に受け入れていただける十分条件について、階層的(下層は上層の構成要素)に整理した物です。




2.取り組みの実例

施策効果の推定

予算配分の最適化

3.「AIの進化」と「分析者の価値」

シナリオ 「AI 2027」 は、2027 年中に AI 研究そのものが AI によって自動化され、何百万もの ASI(人工超知能)が人間の理解を超えたタスクを迅速に実行する可能性を描き、AI の進化過程で “競争を続けるか・意図的に減速するか”という選択を提示しています。

そのような状態に至っても尚、分析者が価値を発揮できるであろう余地として、以下の 3 要素を挙げます。

おわりに

AI の急速な進化に伴う日々の取り組みの変化の様に、本論 3 での見解も近い内には変化しているのだろうと思います。
また、AI の動向を追うことが重要な様に、数理統計、各業界の市況などの理解、それらを活用するための思考の整理や言語化なども重要です。
広く知見をアップデートし続け、有益な手段を選定し、事業成長に資する分析者でありたい想いです。

参考資料

ai-2027.com

*1:社内リソースや、社外クライアント様より頂戴する分析費用

【RubyKaigi 2025】「RuboCop: Modularity and AST Insights」の3つのポイント紹介

こんにちは。ラクスル事業部 Webエンジニアの西元・森田です。

私達は先日開催されたRubyKaigi 2025 に参加してきました。
多くの興味深い発表がありましたが、その中でも特にRuboCop: Modularity and AST Insightsに注目しました。
このブログでは発表の3つのポイント「プラグイン・アドオン・AST」についてまとめて紹介します。

1. RuboCop プラグイン(RuboCop 1.72以降)

RuboCopにはデフォルトでさまざまなルールが用意されていますが、標準機能だけでは特定のライブラリに特化したチェックには対応しきれないため、カスタムCopが広く使われています。ここでのカスタムCopには、公式のrubocop-railsrubocop-rspecに加え、サードパーティ製のものも含まれます。

RuboCop 1.72以降では、こうしたカスタムCopを扱うためにPluginという仕組みが導入されました。これによって、「rubocop.ymlrequire:の代わりにplugin:を使うことが推奨されるようになった」というユーザ影響があったものの、変更の背景はあまり知られていませんでした。

RubyKaigi 2025では、このPluginが導入された背景について発表がありました。

RuboCopの設定統合に関する課題

RuboCopはこれまで、カスタムCopを定義するためのインターフェイスRuboCop::Base こそ用意していたものの、カスタムcopをRuboCop本体の設定とマージする仕組みは不十分でした。
そのため、多くのカスタムCopはRuboCop::ConfigLoaderという、RuboCopの内部設定に関わるクラスのメソッドを直接呼び出して、設定を直接書き換えてしまっていました。

このアプローチには2つの問題がありました。
まず、カスタムcopのコードがRuboCop本体のConfigLoaderの実装詳細に強く依存する、密結合状態になっていたことです。
また、複数のカスタムCopを併用した際、読み込み順序によって設定結果が変化するという問題も発生していました。

LintRoller導入とその効果

この課題を解決するため、RuboCop 1.72以降では設定を一元管理するLintRollerという仕組みが導入されました。カスタムCopはLintRoller::Pluginを継承して設定を登録し、RuboCop本体はそこから設定を受け取る形となりました。

# rubocop-rspec/lib/rubocop/rspec/plugin.rb
    class Plugin < LintRoller::Plugin

これによって、カスタムCopがRuboCop本体の設定を直接干渉する必要がなくなり、設定の競合に関する問題も解消されました。

RuboCopプラグイン導入に伴う変更点

  • ユーザ向け
    冒頭でも書いた通り、今後はrubocop.ymlで従来のrequire:の代わりに、plugin:を記述する必要があります。

  • 開発者向け
    新たにLintRoller::Pluginを継承したクラスを定義する必要があります。
    また、ゼロからPluginを作成するのが面倒な場合は、rubocop-extension-generator というgemを使うことで、雛形を生成できるようです。

2. アドオン

Ruby LSPは、Shopifyが提供している拡張機能で、IDE上でのコード補完や定義ジャンプといった豊富な機能を持っています。
しかし、この拡張機能はパース処理に関する課題を抱えていました。

Ruby LSPのパース処理に関する課題

2024年時点では、Ruby LSP内の各機能がそれぞれ同じコードを個別にパースしていました。さらにRuboCopを併用する場合、RuboCop側でも別途パース処理が走るため、パフォーマンスの観点で非効率となっていました。

そこで、Ruby LSPの開発者は、Ruby LSPで生成したパース結果をRuboCopで再利用すれば無駄を避けられると考え、RuboCopとの連携を検討し始めました。

しかし、Ruby LSPではすでにPrismによるパースが行われていたもの、RuboCop(rubocop-ast)はこの時点でPrismに非対応で、ASTの形式が一致しないため、そのままでは利用できませんでした。
一時的にPrismのASTをrubocop-ast互換に変換するモンキーパッチが導入されたものの、長期的に利用するのは難しいとされていました。

Ruby LSPとRuboCopの協調

2024年3月にリリースされたRuboCop v1.62では、AST解析エンジンとしてPrismをオプショナルで利用できるようになりました。
また、Ruby LSPがすでに生成したASTをRuboCopに直接渡すための構成も整備されました。(対象プルリクエスト:rubocop-ast PR #359 Enable reusable Prism parse result

対応前は、初期化時に内部でPrismが再実行されていました。

module RuboCop::AST
  class ProcessedSource
    def initialize(source, ruby_version, path = nil, parser_engine: :parser_whitequark)
      parse(source, ruby_version, parser_engine)
    end
  end
end

対応後は、もしパース済みの結果があれば、そのまま受け取れるようになりました。

module RuboCop::AST
  class PrismParsed
    def initialize(prism_result)
      @prism_result = prism_result
    end
  end
end

発表によると、実際にこの方法を適用した場合、RuboCopによるソースコードの解析時間は、再パースを行う場合に比べて約1.3倍高速化されたそうです。

今後の展望

構文解析の重複問題こそ解消されましたが、Ruby LSP内のRuboCopアドオンにはまだ改善の余地があります。たとえば、rubocop:disableコメントの自動挿入など、RuboCop拡張機能に備わっている機能が不十分なようです。

こうした機能がRuby LSP側にも対応し、正式なアドオンとして提供されれば、開発体験は確実に高まるため、引き続き注目していきたいです。

3. AST Insights

発表の中ではParser gemからPrismへパーサーを変更したことの背景やRuboCopのパーサーに関する今後についての話がされていました。

ここでは前回のTechBlog【RubyKaigi 2025参加者向け】RuboCopに関する2024年発表内容振り返り&今年の見どころを紹介 の内容をかいつまみつつ、Prismを使ってParser gemのASTを出力している部分について少し調査したことを書いていきます。

Parser gemの課題とPrismへの移行の背景

これまで、RuboCopはRubyコードの解析にParser gemを利用してきました。Parser gemは強力なパーサーライブラリですが、メンテナンスがコミュニティに依存しており、Rubyの進化に追随するための継続的なメンテナンスが課題でした。

一方PrismはRuby公式が開発を進めている新しいパーサーであり、以下の特長を持ちます。

  • エラートレランス
  • C言語実装による高速性
  • Ruby 3.4以降、推奨パーサーとなったことによるメンテナンスの安定性

これらの特長や後述するParser gem ASTへの変換機能があることから、RuboCopのデフォルトパーサーがPrismへ変更されました。
今後はPrismに直接依存するため、もしバグの要因がPrismにあればPrism自体が修正されることでRuboCopでもそのままバグが改善される可能性も高まります。これにより、Parser gemのメンテナンスに割いていたコストを、本来力を入れたいRuboCop開発に集中させることができます。

RuboCopのパーサー変更の決め手の1つとなったAST変換機能

Prismへのパーサー変更には多くの利点がありますが、RuboCopはこれまでParser gemのASTを前提として構築されてきました。そのため、Prismに変更することでASTが変わってしまうと、その影響範囲は甚大です。
RuboCop自体はもちろん、カスタムCopを利用している場合の検証ロジックがParser gemの出力するASTに基づいていました。したがって、ASTが変更されると、各カスタムCopの利用者側でも修正が必要となり、全面的な移行を実現できる状況にありませんでした。

しかしPrismでは、Parser gem ASTへ変換するAdapterであるPrism::Translation::Parserを用意しており、これを使うことでRuboCopのパーサー変更が現実的なものとなりました。
講演の中でも触れられていましたが、現状のRuboCopではASTの形式を変えなければいけないような課題はなく、AST自体は引き続きParser gemの形式を利用することに問題はないとの見解でした。

そこで、パース処理にはPrismを採用しつつ、RuboCop内で参照するASTはParser gem ASTのままとするという方針で改善が進められました。

つまり、パーサー自体がParser gemであれPrismであれ、そこに依存することはなく、最終的にParser gem ASTが取得できれば問題ないという設計です。

ASTのインターフェイスのみに依存する設計となったことで、将来的にPrismがLramaや他のパーサーに置き換わったとしても、最終的にParser gem ASTに変換可能であれば、RuboCop側の修正を最小限に抑えつつパーサーの進化による恩恵を受け続けられるということです。

Prism::Translation::Parser

Prism::Translation::Parserには主に下記の機能が実装されています。

  • parse
    • 広く使われているParser gemの parse メソッドと同様のインターフェイスで、Parser gem ASTを返すメソッドです。
  • parse_with_comments
    • ソースコードの解析と同時にコメントも抽出し、抽出されたコメントはParser::Source::Commentオブジェクトへと変換します。
    • これにより、コードの解析結果に加え、コメントの位置情報などもParser gemの形式で利用することが可能です。
  • tokenize
    • Prismを用いてソースコードの字句解析(トークン化)を行い、その結果得られたトークンをParser gemのトークン形式へ変換します。

Prism::Translation::ParserでParser gem ASTを取得してみる

たとえば puts 'こんにちは' というコードをパースした場合、PrismとParser gemではそれぞれ次のようなASTを取得できます。

  • Prism AST

        @ CallNode (location: (1,0)-(1,22))
        ├── flags: newline, ignore_visibility
        ├── receiver: ∅
        ├── call_operator_loc: ∅
        ├── name: :puts
        ├── message_loc: (1,0)-(1,4) = "puts"
        ├── opening_loc: ∅
        ├── arguments:
        │   @ ArgumentsNode (location: (1,5)-(1,22))
        │   ├── flags: ∅
        │   └── arguments: (length: 1)
        │       └── @ StringNode (location: (1,5)-(1,22))
        │           ├── flags: ∅
        │           ├── opening_loc: (1,5)-(1,6) = "'"
        │           ├── content_loc: (1,6)-(1,21) = "こんにちは"
        │           ├── closing_loc: (1,21)-(1,22) = "'"
        │           └── unescaped: "こんにちは"
        ├── closing_loc: ∅
        └── block: ∅
    
  • Parser gem AST

        s(:send, nil, :puts,
          s(:str, "こんにちは"))
    

これらのAST出力を試したときの詳細については 【RubyKaigi 2025参加者向け】RuboCopに関する2024年発表内容振り返り&今年の見どころを紹介 に乗せていますので、ご興味があればぜひ見てください。

講演で紹介があったように、Prism::Translation::ParserにはParser gemのASTを取得するメソッドが実装されています。
その主要な機能であるparseメソッドを使ってParser gemのASTを出力してみましょう。

require "prism"
require "prism/translation/parser"

source = "puts 'こんにちは'"

ast = Prism::Translation::Parser.parse(source)
puts ast.inspect
# => s(:send, nil, :puts,
#      s(:str, "こんにちは"))

RuboCopでは現在使われていませんが、もしコードに含まれるコメントもASTに反映させたい場合には、parse_with_commentsメソッドを利用できます。

require "prism"
require "prism/translation/parser"

source = <<-RUBY
  # Hello!
  puts 'こんにちは'
RUBY

ast = Prism::Translation::Parser.parse_with_comments(source)
puts ast.inspect
# => [s(:send, nil, :puts,
#      s(:str, "こんにちは")), [#<Parser::Source::Comment (string):1:3 "# Hello!">]]

Prism::Translation::Parser の処理を少し追ってみる

Prism::Translation::Parser#parseメソッドの中身を見てみると下記のようになっています。

def parse(source_buffer)
  @source_buffer = source_buffer
  source = source_buffer.source

  # Prismはバイト単位でオフセットを持つが、Parser gem は文字単位でオフセットを持つのでその差分を計算
  offset_cache = build_offset_cache(source)
  # @parserはPrismを指し、Prismのparseメソッドを呼んでPrism ASTを生成
  result = unwrap(@parser.parse(source, **prism_options), offset_cache)

  # Prism AST から Parser gem AST に変換
  build_ast(result.value, offset_cache)
ensure
  @source_buffer = nil
end

Prism ASTからParser gem ASTへの変換を行うbuild_astメソッドはprivateメソッドとして定義されています。そのため、Prismを利用するアプリケーション側ではこの変換処理を意識する必要はありません。単純なケースであれば、パーサーのクラス名をParser::CurrentRubyなどからPrism::Translation::Parserに変更するだけで、Prismを使ってParser gem ASTを取得できるようになります。

参考

まとめ

普段ユーザとして利用している際は気づかなかったものの、RuboCopの拡張性や利便性においてさまざまな課題があり、この1年だけでも着実に解決されていることがわかって興味深かったです。

今後も情報をキャッチアップし、RuboCopの進化に追従していきたいです。