0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

todo-apiのAI機能編:Vertex AIでタイトルから自動カテゴリ分類する

0
Posted at

この記事について

ここまででtodo-apiは、DB化(第2回)・IaC化(第3回)・バックアップ/DR(第4回)・監視(第5回)・ログの分析基盤化(第6回)と、インフラ面の土台をひととおり固めてきました。今回は少し毛色を変えて、アプリの機能自体にAIを組み込む回です。

todoを作成するとき、今はtitleをただ保存するだけです。今回はここにVertex AI(Gemini)を使って、タイトルの内容から自動でカテゴリを推測し、categoryとして一緒に保存するようにします。

  • Cloud SQLのtodosテーブルにcategoryカラムを追加する
  • todo作成時に、Vertex AI(Gemini)にタイトルを渡してカテゴリを判定させる
  • 判定は「仕事」「買い物」「家事」などの固定カテゴリから1つ選ばせる方式にする
  • Terraformでvertex_ai.tfを新規追加し、Cloud RunのサービスアカウントにVertex AI呼び出し権限を付与する
  • 実際にtodoを作成して、カテゴリが自動で付くところまで確認する

第6回でBigQueryに溜めたタイトルの傾向データも、実はカテゴリ設計の参考になりました。ログを溜めるだけで終わらせず、次の機能に繋げられたのは地味に嬉しいポイントです。

全体構成

スクリーンショット 2026-09-24 000946.png

[クライアント]
      │ POST /api/todos { "title": "牛乳を買う" }
      ▼
[todo-api (Cloud Run)]
      │
      │ タイトルを渡してカテゴリ判定を依頼
      ▼
[Vertex AI (Gemini)]
      │
      │ 固定カテゴリの中から1つ選んで返す
      ▼
[todo-api]
      │
      │ title + category をまとめて保存
      ▼
[Cloud SQL: todos テーブル]

todo作成のリクエストの中でVertex AIを同期的に呼び出し、判定結果をそのままレスポンスに含めて返す方式にします。バックグラウンドジョブは組まず、まずはシンプルな形で動かします。

1. Cloud SQLにcategoryカラムを追加する

まず保存先の準備です。ローカルからCloud SQL Auth Proxy経由で接続し、todosテーブルにcategoryカラムを追加します。

psql -h 127.0.0.1 -p 5432 -d todoapp -U appuser -c "ALTER TABLE todos ADD COLUMN category text;"

既存の行にはcategoryがNULLのまま残ります。今回は「新規作成分だけ自動でカテゴリが付く」を目標にするので、既存データの一括分類はスコープ外にします。

このpsqlでのALTER TABLEは本番のCloud SQLだけに効きます。GitHub Actionsのテスト環境は毎回まっさらなDBから起動するため、これだけだとCI側のスキーマには反映されず、テストがcolumn "category" does not existで落ちます。テーブル定義を実際に管理しているのはsrc/db.tsのensureSchema()なので、そちらも合わせて直します。

src/db.tsのensureSchema()を以下のように修正します。

export async function ensureSchema(): Promise<void> {
    await pool.query(`
        CREATE TABLE IF NOT EXISTS todos (
            id SERIAL PRIMARY KEY,
            title TEXT NOT NULL,
            done BOOLEAN NOT NULL DEFAULT FALSE,
            category TEXT
        )
    `)
    await pool.query(`
        ALTER TABLE todos ADD COLUMN IF NOT EXISTS category TEXT
    `)
}

CREATE TABLE IF NOT EXISTSの列定義は、テーブルがまだ存在しない場合にしか効きません。そのため、新規作成時にも既存テーブルへの追加時にも両方効くように、ALTER TABLE ... ADD COLUMN IF NOT EXISTSを続けて実行する形にしています。これで、CIのようにまっさらな状態から始まる環境と、既にカラムを追加済みの本番環境の両方を1つのコードでカバーできます。

2. アプリにVertex AI呼び出しを追加する

@google-cloud/vertexaiパッケージを使います。

npm install @google-cloud/vertexai

2-1. カテゴリ判定処理を追加する

新しくsrc/classify.tsを作成します。

import { VertexAI } from "@google-cloud/vertexai";

const CATEGORIES = ["仕事", "買い物", "家事", "健康", "学習", "その他"] as const;

const vertexAI = new VertexAI({
  project: process.env.GOOGLE_CLOUD_PROJECT ?? "my-hono-app-v2",
  location: "asia-northeast1",
});

const model = vertexAI.getGenerativeModel({
  model: "gemini-2.5-flash",
});

export async function classifyTitle(title: string): Promise<string> {
  const prompt = `次のtodoのタイトルを読んで、以下のカテゴリの中から最も当てはまるものを1つだけ選んでください。
カテゴリ以外の文字は一切出力しないでください。

カテゴリ: ${CATEGORIES.join(", ")}

タイトル: "${title}"`;

  try {
    const result = await model.generateContent(prompt);
    const text = result.response.candidates?.[0]?.content?.parts?.[0]?.text?.trim();

    if (text && (CATEGORIES as readonly string[]).includes(text)) {
      return text;
    }
    return "その他";
  } catch (error) {
    console.error(JSON.stringify({ event: "classify_error", message: String(error) }));
    return "その他";
  }
}

カテゴリを固定の配列で持っておいて、Geminiの返答がその中に含まれていなければ"その他"にフォールバックしているのがポイントです。自由記述にすると、レスポンスの揺れ(「買い物系」「ショッピング」など表記ゆれ)であとの集計がしづらくなるので、今回はここを厳格にしています。

2-2. todo作成処理から呼び出す

src/todos.tsのtodo作成処理に、classifyTitleの呼び出しを追加します。

import { classifyTitle } from "./classify";

// todo作成処理の中
const category = await classifyTitle(title);

const newTodo = await db.query(
  "INSERT INTO todos (title, done, category) VALUES ($1, $2, $3) RETURNING *",
  [title, false, category]
);

console.log(JSON.stringify({
  event: "todo_operation",
  operation: "create",
  todoId: newTodo.rows[0].id,
  title: newTodo.rows[0].title,
  category: newTodo.rows[0].category,
  timestamp: new Date().toISOString(),
}));

第6回で仕込んだ構造化ログにもcategoryを混ぜ込んでおきました。これで次にBigQueryで集計するときに「カテゴリ別のtodo件数」もそのまま見られるようになります。

3. Terraformで権限を追加する

terraform/vertex_ai.tfを新規作成します。既存のファイルは編集しません。

terraform/
├── backend.tf
├── provider.tf
├── artifact_registry.tf
├── cloud_sql.tf
├── cloud_run.tf
├── secret_manager.tf
├── iam.tf
├── monitoring.tf
├── bigquery.tf
└── vertex_ai.tf   ← 今回これを新規作成

Vertex AI(Agent Platform API)自体がプロジェクトで有効化されていないと呼び出せないので、まずAPIの有効化をTerraformで管理します。terraform/vertex_ai.tfに以下を追記します。

resource "google_project_service" "aiplatform" {
  project = var.project_id
  service = "aiplatform.googleapis.com"

  disable_on_destroy = false
}

disable_on_destroy = falseにしているのは、terraform destroyした時にAPI自体を無効化されると他のリソースにも影響しかねないためです。

続けて、Cloud Runの実行サービスアカウントに、Vertex AIを呼び出す権限を付与します。

resource "google_project_iam_member" "cloud_run_vertex_ai_user" {
  project    = var.project_id
  role       = "roles/aiplatform.user"
  member     = "serviceAccount:${data.google_project.current.number}-compute@developer.gserviceaccount.com"
  depends_on = [google_project_service.aiplatform]
}

depends_onを付けて、APIの有効化が終わってから権限付与が行われる順序を保証しています。

cloud_run.tfでgoogle_cloud_run_v2_serviceのtemplate.service_accountを明示指定していない場合、Cloud RunはデフォルトのCompute Engineサービスアカウント(<プロジェクト番号>-compute@developer.gserviceaccount.com)で動きます。第3回・第5回では専用のサービスアカウントは作らず、この既定のSAをそのまま使う構成になっていたので、今回もそれに権限を足す形にしています。data "google_project" "current"はiam.tfで既に宣言されているものをそのまま参照しています(同じディレクトリ内のdata/resourceはファイルをまたいで共有されるので、重複して宣言する必要はありません)。もし独自のサービスアカウントをcloud_run.tfで指定している場合は、そちらのリソース(google_service_account.xxx.email)を参照する形に読み替えてください。

3-1. 適用

cd terraform
$env:TF_VAR_db_password = "appuserのパスワード"
$env:TF_VAR_alert_email = "your-email@example.com"
terraform plan -out=tfplan
terraform apply tfplan

4. デプロイして、実際にカテゴリが付くか確認する

スクリーンショット 2026-09-24 000954.png

コードとインフラの変更をデプロイします。

git add src/classify.ts src/todos.ts package.json package-lock.json
git commit -m "Vertex AIでtodoのタイトルから自動カテゴリ分類"
git push

GitHub Actionsの完了を待ってから、実際にtodoを作成してみます。

[System.IO.File]::WriteAllText("$PWD\body.json", '{"title":"牛乳を買う"}')
curl.exe -X POST "$env:TODO_API_URL/api/todos" -H "Content-Type: application/json" -d "@body.json"

レスポンスのcategoryに"買い物"のような値が入っていれば成功です。念のため何件か毛色の違うタイトルで試して、それぞれ妥当なカテゴリが付くか見てみるとわかりやすいです。

[System.IO.File]::WriteAllText("$PWD\body.json", '{"title":"資料を作成する"}')
curl.exe -X POST "$env:TODO_API_URL/api/todos" -H "Content-Type: application/json" -d "@body.json"

[System.IO.File]::WriteAllText("$PWD\body.json", '{"title":"部屋を掃除する"}')
curl.exe -X POST "$env:TODO_API_URL/api/todos" -H "Content-Type: application/json" -d "@body.json"

DB側でも直接確認できます。

psql -h 127.0.0.1 -p 5432 -d todoapp -U appuser -c "SELECT id, title, category FROM todos ORDER BY id DESC LIMIT 5;"

roles/aiplatform.userの権限付与直後は、反映まで数分かかることがあります。デプロイ直後にPermissionDeniedのようなエラーが出た場合は、少し待ってからもう一度試してみてください。

また、実際にやってみると次の2段階でエラーにぶつかりました。1つ目はaiplatform.googleapis.com(Agent Platform API)自体が未有効化による403 SERVICE_DISABLED。2つ目は、有効化後にgemini-2.0-flashというモデル名がasia-northeast1・us-central1のどちらでも404 Not Foundになる、というものでした。モデルの提供状況やIDは変わりやすいため、記事のコードをそのまま使って404が出た場合は、gemini-2.5-flashのような別バージョンを試すか、Cloud ConsoleのVertex AI → Model Gardenで実際に使えるモデルIDを確認してみてください。今回はgemini-2.5-flashに変更したところ動作しました。

5. これで何が変わったか

  • todoを作るだけで、タイトルの内容に応じたカテゴリが自動で付くようになった
  • 判定を固定カテゴリに絞ったことで、あとからの集計(第6回のBigQueryとの組み合わせ)がしやすい形になっている
  • Vertex AIの呼び出し権限もTerraformでコード管理下にあり、terraform applyで再現できる

まとめ

  • Cloud SQLのtodosテーブルにcategoryカラムを追加し、src/classify.tsでVertex AI(Gemini)を使ったカテゴリ判定処理を実装した
  • カテゴリは固定の一覧から1つだけ選ばせる方式にし、想定外の返答は"その他"にフォールバックさせることで表記ゆれを防いだ
  • vertex_ai.tfでCloud RunのサービスアカウントにVertex AI呼び出し権限を付与し、第6回までのTerraformコードに統合した
  • 実際にtodoを作成して、タイトルの内容に応じたカテゴリが自動で付くところまで確認した

インフラを固めて、ログを分析基盤に流して、そのデータを設計の参考にしながらAI機能を足す。ここまでの6回が全部繋がった形になった。次は、このcategoryをBigQuery側の集計にも組み込んで、「カテゴリ別の傾向」をもう一段掘り下げてみたい。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?