LangChainでAI駆動開発を自動化する:要件定義からテストまでフェーズ別エージェント実装ガイド

LangChainでAI駆動開発を自動化する:要件定義からテストまでフェーズ別エージェント実装ガイド

うちのチームに新しいメンバーが入ってきて、「LangChainを使えば開発全部自動化できますよね?」と言い出しまして。気持ちはわかるんですが、そこには大きな落とし穴があります。要件定義で使うべき思考と、テストケースを考えるときの思考は、根本的に違う。それを1つのエージェントに丸投げするとどうなるか——自分で試して痛い目を見ました。この記事では、フェーズごとに専門エージェントを分業させるアーキテクチャをLangChainで実装する方法を、実際に動くコードとともに解説します。

注目ポイント

エージェントはステートレスに設計し、フェーズ間の引き渡しは「ドキュメントオブジェクト」で行うのが安定運用の鍵。コンテキストを引き継ぐのではなく、前フェーズの成果物を次フェーズへの入力として渡す。

なぜ「1エージェント・全工程」は破綻するのか

LangChainのReActエージェントやLCELチェーンを使って「ユーザーの要望を入力したら設計書とコードとテストが出てくる」パイプラインを作ろうとした経験がある方は多いと思います。最初の数回は動く。でも少し複雑な要件を入れた途端、出力が崩れ始める。

原因はシンプルで、LLMのコンテキストウィンドウには限界があるからです。要件定義の議論をしながら、同時に実装コードの詳細まで考えさせると、どちらも中途半端になる。さらに問題なのは、エラーになるのではなく「それっぽい何か」が出力されてしまうこと。これが一番タチが悪い。

よくある失敗パターン

「1つの巨大プロンプトに全フェーズの指示を詰め込む」アプローチ。最初は動いて見えるが、要件が複雑になった瞬間に崩壊する。プロンプトエンジニアリングで解決しようとするより、設計を分けるほうが早い。

全体設計と前提環境

今回実装する構成はこうなります。4つの専門エージェントを順番に実行し、各エージェントの出力をPydanticモデルで型定義した「ドキュメントオブジェクト」として次のエージェントに渡します。

# 全体のフロー
ユーザー入力(要件メモ)
    ↓
RequirementsAgent  → RequirementsDoc(曖昧さリスト・確定仕様)
    ↓
DesignAgent        → DesignDoc(アーキテクチャ・API定義・DB設計)
    ↓
CodingAgent        → CodeDoc(実装コード・ファイル構成)
    ↓
TestAgent          → TestDoc(テストケース・テストコード)

前提環境

pip install langchain langchain-community langchain-core
pip install pydantic ollama

# Ollamaでモデルを起動しておく
ollama run gpt-oss:20b

OpenAI APIを使う場合は langchain-openai に差し替えるだけです。今回はローカルLLM(Ollama)ベースで書きますが、モデル部分は差し替え可能な設計にします。

要件定義エージェント:曖昧さを構造化する

要件定義エージェントの仕事は2つ。ひとつは入力テキストから曖昧な箇所を検出して質問リストを生成すること、もうひとつは確定した仕様を構造化されたオブジェクトに変換することです。

from langchain_community.llms import Ollama
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import JsonOutputParser
from pydantic import BaseModel, Field
from typing import List

# 出力の型定義
class RequirementsDoc(BaseModel):
    ambiguities: List[str] = Field(description="曖昧な点・確認が必要な事項のリスト")
    confirmed_specs: List[str] = Field(description="確定した仕様のリスト")
    constraints: List[str] = Field(description="技術的・ビジネス的制約のリスト")
    summary: str = Field(description="要件の概要(2〜3文)")

class RequirementsAgent:
    def __init__(self, model_name: str = "gpt-oss:20b"):
        self.llm = Ollama(model=model_name, temperature=0.1)
        self.parser = JsonOutputParser(pydantic_object=RequirementsDoc)
        self.prompt = ChatPromptTemplate.from_messages([
            ("system", """あなたはシニアのシステムアナリストです。
入力された要件テキストを分析し、以下を必ずJSON形式で出力してください。
- 定義が曖昧で確認が必要な項目
- 確定した仕様
- 技術的・ビジネス的制約
- 要件の概要

{format_instructions}"""),
            ("human", "以下の要件を分析してください:\n\n{requirements_text}")
        ])

    def run(self, requirements_text: str) -> RequirementsDoc:
        chain = self.prompt | self.llm | self.parser
        result = chain.invoke({
            "requirements_text": requirements_text,
            "format_instructions": self.parser.get_format_instructions()
        })
        return RequirementsDoc(**result)
設計のポイント

temperature=0.1 に設定するのがミソ。要件定義は「創造性」より「網羅性・一貫性」が重要なので、低めのtemperatureで安定した出力を得る。

設計エージェント:仕様書からアーキテクチャを生成する

設計エージェントは RequirementsDoc を受け取り、アーキテクチャ案・API定義・DBスキーマを出力します。ここでのポイントは、前フェーズの「曖昧さリスト」も一緒に渡すこと。曖昧なままの仕様を無視した設計を出力させないためです。

class DesignDoc(BaseModel):
    architecture: str = Field(description="アーキテクチャの説明(テキスト)")
    api_endpoints: List[dict] = Field(description="APIエンドポイント定義リスト")
    db_schema: List[dict] = Field(description="DBテーブル定義リスト")
    tech_stack: List[str] = Field(description="使用技術スタック")
    assumptions: List[str] = Field(description="設計上の前提・仮定(曖昧仕様への対応)")

class DesignAgent:
    def __init__(self, model_name: str = "gpt-oss:20b"):
        self.llm = Ollama(model=model_name, temperature=0.2)
        self.parser = JsonOutputParser(pydantic_object=DesignDoc)
        self.prompt = ChatPromptTemplate.from_messages([
            ("system", """あなたはシニアのソフトウェアアーキテクトです。
確定仕様と制約に基づいてシステム設計を行い、JSON形式で出力してください。
曖昧な仕様については、設計上の前提として assumptions に明記してください。

{format_instructions}"""),
            ("human", """確定仕様: {confirmed_specs}
制約: {constraints}
曖昧な点(前提として扱う): {ambiguities}
要件概要: {summary}""")
        ])

    def run(self, req_doc: RequirementsDoc) -> DesignDoc:
        chain = self.prompt | self.llm | self.parser
        result = chain.invoke({
            "confirmed_specs": "\n".join(req_doc.confirmed_specs),
            "constraints": "\n".join(req_doc.constraints),
            "ambiguities": "\n".join(req_doc.ambiguities),
            "summary": req_doc.summary,
            "format_instructions": self.parser.get_format_instructions()
        })
        return DesignDoc(**result)

実装エージェント:設計書をコードに落とす

実装エージェントは DesignDoc のAPIエンドポイント定義とDBスキーマを受け取り、コードを生成します。全体を一度に生成しようとするとコンテキスト超過するので、エンドポイントごとに分割して呼び出す設計にしています。

class CodeDoc(BaseModel):
    files: List[dict] = Field(description="生成したファイルのリスト(filename, content)")
    setup_instructions: str = Field(description="セットアップ手順")

class CodingAgent:
    def __init__(self, model_name: str = "gpt-oss:20b"):
        self.llm = Ollama(model=model_name, temperature=0.15)
        self.prompt = ChatPromptTemplate.from_messages([
            ("system", """あなたはシニアのバックエンドエンジニアです。
設計書に基づいてPythonコードを生成してください。
- FastAPIを使用
- 型ヒントを必ず付ける
- docstringは英語で記述
- エラーハンドリングを適切に実装"""),
            ("human", """以下のAPIエンドポイントを実装してください:
{endpoint}

DBスキーマ: {db_schema}
技術スタック: {tech_stack}""")
        ])

    def run(self, design_doc: DesignDoc) -> CodeDoc:
        all_files = []
        # エンドポイントごとに分割生成
        for endpoint in design_doc.api_endpoints:
            chain = self.prompt | self.llm
            code = chain.invoke({
                "endpoint": str(endpoint),
                "db_schema": str(design_doc.db_schema),
                "tech_stack": ", ".join(design_doc.tech_stack)
            })
            all_files.append({
                "filename": f"api_{endpoint.get('path','').strip('/').replace('/','_')}.py",
                "content": code
            })
        return CodeDoc(
            files=all_files,
            setup_instructions=f"Tech stack: {', '.join(design_doc.tech_stack)}"
        )

テストエージェント:コードからテストを生成する

テストエージェントは生成されたコードファイルを受け取り、pytestベースのテストコードを生成します。ここでもファイルごとに分割呼び出しです。一度に全コードを渡すとテストの質が落ちる。

class TestDoc(BaseModel):
    test_files: List[dict] = Field(description="テストファイルのリスト(filename, content)")
    coverage_notes: List[str] = Field(description="カバレッジ上の注意点・未テスト箇所")

class TestAgent:
    def __init__(self, model_name: str = "gpt-oss:20b"):
        self.llm = Ollama(model=model_name, temperature=0.1)
        self.prompt = ChatPromptTemplate.from_messages([
            ("system", """あなたはQAエンジニアです。pytestを使ったテストコードを生成してください。
- 正常系・異常系・境界値を網羅
- モックは unittest.mock を使用
- テスト関数名は test_[動詞]_[対象]_[条件] の形式で"""),
            ("human", "以下のコードのテストを生成してください:\n\n{source_code}")
        ])

    def run(self, code_doc: CodeDoc) -> TestDoc:
        test_files = []
        for file in code_doc.files:
            chain = self.prompt | self.llm
            test_code = chain.invoke({"source_code": file["content"]})
            test_files.append({
                "filename": f"test_{file['filename']}",
                "content": test_code
            })
        return TestDoc(
            test_files=test_files,
            coverage_notes=["境界値テストは手動での確認を推奨"]
        )

オーケストレーション:4エージェントをパイプラインで繋ぐ

最後に、4つのエージェントを順番に実行するオーケストレーターを実装します。各フェーズの結果をログに残しておくと、どこで品質が落ちたか後から追跡できます。

import json
from datetime import datetime
from pathlib import Path

class DevPipeline:
    def __init__(self, model_name: str = "gpt-oss:20b", output_dir: str = "output"):
        self.req_agent = RequirementsAgent(model_name)
        self.design_agent = DesignAgent(model_name)
        self.coding_agent = CodingAgent(model_name)
        self.test_agent = TestAgent(model_name)
        self.output_dir = Path(output_dir)
        self.output_dir.mkdir(exist_ok=True)

    def run(self, requirements_text: str) -> dict:
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        results = {}

        print("▶ Phase 1: 要件定義エージェント")
        req_doc = self.req_agent.run(requirements_text)
        results["requirements"] = req_doc.model_dump()
        self._save(f"{timestamp}_requirements.json", results["requirements"])
        print(f"  曖昧な点: {len(req_doc.ambiguities)}件 / 確定仕様: {len(req_doc.confirmed_specs)}件")

        print("▶ Phase 2: 設計エージェント")
        design_doc = self.design_agent.run(req_doc)
        results["design"] = design_doc.model_dump()
        self._save(f"{timestamp}_design.json", results["design"])
        print(f"  APIエンドポイント: {len(design_doc.api_endpoints)}件")

        print("▶ Phase 3: 実装エージェント")
        code_doc = self.coding_agent.run(design_doc)
        results["code"] = code_doc.model_dump()
        self._save(f"{timestamp}_code.json", results["code"])
        print(f"  生成ファイル: {len(code_doc.files)}件")

        print("▶ Phase 4: テストエージェント")
        test_doc = self.test_agent.run(code_doc)
        results["tests"] = test_doc.model_dump()
        self._save(f"{timestamp}_tests.json", results["tests"])
        print(f"  テストファイル: {len(test_doc.test_files)}件")

        return results

    def _save(self, filename: str, data: dict):
        with open(self.output_dir / filename, "w", encoding="utf-8") as f:
            json.dump(data, f, ensure_ascii=False, indent=2)

# 実行例
if __name__ == "__main__":
    pipeline = DevPipeline(model_name="gpt-oss:20b")
    requirements = """
    ユーザー管理APIを作りたい。
    ユーザーの登録・ログイン・プロフィール更新ができること。
    認証はJWTを使う。DBはPostgreSQLを想定。
    """
    results = pipeline.run(requirements)
運用Tips

各フェーズの出力をJSONファイルとして保存しておくと、途中のフェーズからやり直せます。要件定義だけ人間がレビューして修正し、設計以降を再実行する、という使い方が現実的です。

実運用での限界と人間の介在ポイント

このパイプラインを実際に使ってみて気づいた限界をいくつか挙げておきます。まず、要件が曖昧なまま進むと雪だるま式に品質が落ちる。RequirementsAgentが出した曖昧さリストを無視してDesignAgentに進むと、設計の前提が崩れ、コードもテストも連鎖的に歪みます。

もうひとつは、テストエージェントのエッジケース認識が甘いこと。正常系は網羅してくれますが、「このAPIに不正なJWTを渡したらどうなるか」といった攻撃的なテストケースは自分で足す必要があります。コストの話をすると、ローカルLLMなのでAPI費用はゼロですが、20Bモデルで全フェーズ回すと5〜10分かかります。小規模プロジェクトなら十分実用的な速度です。

人間が介在すべきポイントは明確で、Phase 1終了後の曖昧さレビューPhase 3終了後のコードレビューの2点。それ以外はエージェントに任せて、人間は判断に集中する——これがこのアーキテクチャの狙いです。

Google Antigravityで変わるAI駆動開発:フェーズ別エージェント分業の実践

Google Antigravityで変わるAI駆動開発:フェーズ別エージェント分業の実践

先月、お客さんから「AI使えばもう設計書いらないですよね?」と言われまして。思わず苦笑いしてしまいました。確かに、Copilotやら何やらで「コードを書く速度」は上がった。でも要件定義でAIに曖昧な指示を投げると、自信満々で的外れなものを返してくる。「1つのAIに全部やらせる」という発想は、もう少し慎重に考えたほうがいいんじゃないかと思っていたんですよね。そこに出てきたのが、GoogleのAntigravityです。

注目ポイント

Antigravityの本質は「コード補完の強化」ではなく、「フェーズごとに専門エージェントを分業させ、人間はレビューに集中する」という設計思想の転換にある。

なぜ「1つのAI」では限界が来るのか

CursorやGitHub Copilotを使っている方なら実感があると思いますが、あのツールは基本的に「今書いているコードの文脈」しか見ていません。要件定義の話をしながら同時に実装コードを提案させると、途端にコンテキストが混乱する。結果、中途半端な出力になる。

これは設計の問題でもあって、要件定義・設計・実装・テストはそれぞれ「考え方の粒度」が全然違います。要件定義は「何を作るか」の議論、設計は「どう作るか」の整理、実装は「書く」作業、テストは「壊す」作業。これを1つのLLMに同時にやらせようとするから歪みが出る。Antigravityはここに目を向けた、という点で面白い。

Google Antigravityとは何者か

2025年11月にGoogleが発表したエージェント・ファーストのIDEです。VS Codeフォークなので、今使っている拡張機能や設定はそのまま使えます。この点はチームへの展開コストが低くて助かりますね。

特徴的な機能は3つです。エージェント・マネージャーでバックグラウンドの複数エージェントを並行稼働させられること、ブラウザ・エージェントがAI自身でドキュメントを調査してスクリーンショットまで撮れること、そしてArtifactsでAIの処理結果をタスクリスト・プラン・動画として可視化できること。この3つが噛み合うと、開発フローが変わります。

要件定義フェーズ:曖昧さを炙り出すエージェント

要件定義で一番つらいのは「曖昧なまま進んでしまうこと」です。お客さんの言う「使いやすいUI」って何なのか、「リアルタイム」って何ミリ秒を指しているのか。ここを詰め切れないまま設計に入ると、後で痛い目を見ます。

Antigravityでは、要件ヒアリングのメモや議事録を投げると、エージェントが「この要件は定義が曖昧です。以下を確認してください」という質問リストをArtifactsとして出力します。人間がやると遠慮が入るこの「ツッコミ」を、AIに任せるのは案外合理的です。

注意

エージェントが出した質問リストをそのままお客さんに送るのは危険です。技術的すぎる、あるいは失礼な表現が混じることがある。あくまで「人間がレビューする素材」として使うのが正解です。

設計フェーズ:Artifactsで叩き台を作らせる

設計フェーズでのAntigravityの使い方は「叩き台の高速生成」です。確定した要件をまとめたドキュメントを渡すと、エージェントがアーキテクチャ案・テーブル設計・APIエンドポイント一覧をArtifactsとして出力します。

これが地味に効いてくる。設計レビューって、ゼロから書いたものを見るより「これでいいか?」と叩き台を渡された方が指摘しやすいんですよね。エンジニア全員が設計を書ける訳じゃないし、特にジュニアメンバーは「何から書けばいいかわからない」という状態になりやすい。Artifactsが最初のたたき台を作ってくれるだけで、議論の密度が上がります。

実装フェーズ:ブラウザ・エージェントに調査を丸投げする

実装中に一番時間を取られるのって、コードを書くことより「ドキュメントを読む時間」じゃないかと思っています。新しいライブラリを使うとき、公式ドキュメントを読んで、Stack Overflowを漁って、GitHubのIssueを確認して…。

Antigravityのブラウザ・エージェントは、AIが自分でブラウザを操作してドキュメントを調査し、「このライブラリでやりたいことを実現するコード例」を持ってきます。自分が読む必要がなくなる、とは言いませんが、調査の起点として使うと明らかに速い。うちのチームでは「まずAntigravityに調べさせて、人間が裏取りする」という流れに変わりつつあります。

テストフェーズ:バックグラウンドで回し続ける

テストエージェントをバックグラウンドで動かしながら実装を続ける、というのがAntigravityの真骨頂です。実装中にエージェントが並行して「このコードに対するテストケース案」を生成し続けてくれる。

正直、テストを書く習慣がないチームには少し敷居が高いですが、「テストの素材をAIに作らせて、人間が選ぶ」というプロセスなら受け入れやすい。全部信用するのは危ないですが(エッジケースの認識が甘いことがある)、カバレッジの穴を埋める用途には十分使えます。

実践メモ

バックグラウンドエージェントはリソースを食います。ローカルマシンのスペックが低いと逆に開発体験が悪化します。チーム導入前にスペック要件を確認しておくのが無難です。

人間がやるべき仕事は何か

ここまで書いてきて思うのは、Antigravityを使いこなすには「何をエージェントに任せ、何を自分で判断するか」のタスク設計センスが問われるということです。ツールが変わっても、要件の本質を掴む力や、設計の良し悪しを判断する経験値は、引き続き人間の仕事です。

コストの話をすると、現時点でAntigravityのエンタープライズプランは月額が安くない。ただ、要件定義のやり直しが1回減るだけで、十分に元が取れる規模感の案件が多いのも事実です。まずは小規模プロジェクトで試して、上司への稟議のネタを作るところから始めるのが現実的かなと思っています。

Androidで縦横斜めを自由スクロール!KotlinでカスタムViewGroup「VHScrollView」を実装する

Androidで縦横斜めを自由スクロール!KotlinでカスタムViewGroup「VHScrollView」を実装する

はじめに

Androidアプリを開発していると、「縦方向だけでなく横方向にも、しかも斜め方向にも自由にスクロールできるViewが欲しい」という場面に出くわすことがあります。地図や大きなスプレッドシート、画像ビューアなどがその典型です。標準の ScrollView は縦方向のみ、HorizontalScrollView は横方向のみと、それぞれ一方向にしか対応しておらず、組み合わせ次第では相互のタッチイベントが干渉しあい、思うような動作が得られないことも少なくありません。

本記事では、Kotlinで FrameLayout を継承して構築した VHScrollView(Vertical-Horizontal ScrollView)クラスの設計思想と実装の全コードを解説します。GestureDetectorCompatOverScroller を組み合わせたAndroid標準コンポーネント活用の実践的な例として、カスタムViewのアーキテクチャを理解したい方にも最適な内容です。

基礎知識・概要

Key Concept

GestureDetectorCompat にジェスチャー解析を委譲しつつ、OverScroller で慣性スクロールの物理計算をOSに任せる「責務の分離」設計が、少ないコード量でSwipe/Fling両対応を可能にしています。

Androidのカスタムスクロールビューを実装する際に欠かせない2つのクラスを整理します。

GestureDetectorCompat: MotionEventの生ストリームを受け取り、「タッチダウン」「ドラッグ(onScroll)」「フリック(onFling)」などの高レベルなジェスチャーイベントに変換してくれるユーティリティクラスです。自力でタッチ座標の差分を計算したり、感度を実装する必要がなく、Androidの標準的な判定ルールを再利用できます。

OverScroller: フリング(弾き飛ばし)時の速度減衰・慣性アニメーションの座標計算を担当するクラスです。毎フレームの描画タイミングで computeScrollOffset() を呼ぶことで現在の理想スクロール位置を取得でき、端末のリフレッシュレートに合わせた滑らかな慣性アニメーションを、OSが計算してくれます。このふたつを組み合わせることで、スクロールロジックの本質(どこまでスクロールするか・何が端か)だけを書けばよくなります。

主要機能と詳細

2つのGestureDetectorの役割分担

VHScrollView の設計上の面白い点は、GestureDetectorCompat を用途別に2つ保持していることです。

  • interceptDetector: onInterceptTouchEvent 専用。子Viewにイベントが届く前に「これはスクロール動作か?」を検知し、isScrolling フラグを立てるためだけに使われます。
  • gestureDetector: onTouchEvent 専用。インターセプト後に配送されるイベントを受け取り、実際のスクロール移動(onScroll)およびフリング開始(onFling)処理を行います。

この分離により、子Viewが独自のタッチ処理(クリック等)を持っていても、サコンフリクトを最小限に抑えた自然なUXを保てます。

onMeasureとUNSPECIFIEDの意味

スクロールビューの根幹となる重要な処理が onMeasure 内の子ビューへの MeasureSpec.UNSPECIFIED 指定です。通常、親Viewは子Viewに「最大でこのサイズまでの表示を許可する」という制約(MeasureSpec)を渡します。しかしスクロールビューがそれをやってしまうと、子ビューが画面サイズに収まるよう自ら縮小してしまい、スクロールする意味がなくなります。UNSPECIFIED(制約なし)を渡すことで、「好きなサイズになっていい」と子ビューに伝え、その結果えられた子ビューの measuredWidth/Height と自身の表示領域との差が、スクロール可能な最大移動量(maxX / maxY)になります。

doScrollByとdoFlingの役割

doScrollBy はドラッグ中の指の移動量(distanceX/Y)をそのままスクロール位置に反映する最もシンプルな関数です。ここで coerceIn(0, maxX) によるクランプ処理(範囲固定)を行い、子Viewの端を超えてスクロールされないようにしています。

一方 doFling では、指を弾いた瞬間の速度(velocityX/Y)を OverScroller.fling() に渡し、以後の座標計算をOSに委ねます。GestureDetectorの返す速度は「指の移動方向」なので、スクロール(コンテンツの移動方向は逆)に合わせてマイナスに反転しているのがポイントです。

実装・実践ガイド:コードの詳細と使い方

computeScrollによるアニメーションループ

Androidのレンダリングループと OverScroller を繋ぐ「のり」が computeScroll() のオーバーライドです。フレームごとのVSYNC信号に合わせて呼ばれるこのメソッドで、scroller.computeScrollOffset() を呼ぶと OverScroller が減速計算を行い、現在フレームでの理想座標(currX/Y)を返してくれます。それを scrollTo() で適用し、最後に ViewCompat.postInvalidateOnAnimation(this) で次フレームの描画を再予約する、という無限ループでアニメーションが継続します。アニメーション終了時は computeScrollOffset()false を返すためループが自然に終了します。

XMLレイアウトへの組み込み方法

使い方はシンプルです。スクロールさせたい大きなビューを、VHScrollView の直下の子(1つだけ)として配置するだけです。

<com.example.vhscrollview.VHScrollView
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <!-- スクロールさせたい巨大なコンテンツView -->
    <ImageView
        android:layout_width="3000dp"
        android:layout_height="2000dp"
        android:src="@drawable/big_map" />

</com.example.vhscrollview.VHScrollView>

これだけで、ユーザーが指を動かした方向(縦・横・斜め)に合わせた自然なスクロールと、指を弾いた際のフリング慣性アニメーションが動作します。

依存関係の追加(build.gradle)

本実装は AndroidX Core の ViewCompatGestureDetectorCompat を利用します。build.gradle(またはbuild.gradle.kts)の dependencies ブロックに以下が含まれていることを確認してください。

dependencies {
    implementation("androidx.core:core-ktx:1.12.0")
}

よくある課題と解決策

注意点

子Viewが RecyclerView など独自スクロールを持つ場合、タッチイベントの競合に注意が必要です。

子ViewにクリックやRecyclerViewが共存する場合

onInterceptTouchEvent でスクロール意図を検知した後にインターセプトする設計により、短いタップは子Viewに届きます。ただし、子View自体がスクロール可能な場合(RecyclerView など)は、ネストスクロールの競合が発生します。その場合は NestedScrollingParent3 インターフェースを追加実装し、requestDisallowInterceptTouchEvent の仕組みを組み合わせた制御が必要です。

端の「バウンス」エフェクトを追加する

現状の実装はコンテンツ端でピタッと止まる設計です(クランプ処理)。iOS風の「端を超えて少し伸びて戻る」バウンスエフェクトを追加したい場合は、OverScroller.fling() に オーバースクロールの許容量を渡す overX/overY パラメーターを設定し、EdgeEffect クラスを組み合わせることで実現できます。

アクセシビリティ(a11y)への対応

カスタムスクロールViewはスクリーンリーダー(TalkBack)からのスクロール操作が考慮されていません。ViewCompat.setAccessibilityDelegate を使ってスワイプアクションを定義するか、onInitializeAccessibilityNodeInfo をオーバーライドしてスクロール可能であることをアクセシビリティツリーに通知することが求められます。

まとめ

本記事では、FrameLayout を継承したカスタムViewGroup「VHScrollView」のKotlin実装を解説しました。設計のポイントを振り返ります。

  • インターセプト用と操作用で GestureDetectorを責務分離 し、子Viewとのタッチ競合を最小化。
  • MeasureSpec.UNSPECIFIED で子Viewを 「好きなサイズ」で計測 し、スクロール可能領域を正しく算出。
  • OverScroller に物理演算を委譲し、computeScroll() ループで OSネイティブ品質の慣性スクロール を実現。

この設計パターンは縦横スクロールに限らず、カスタムジェスチャー操作全般に応用が利きます。地図ビューアや大型レイアウトの閲覧UI、図面エディタのベースコンポーネントとしてぜひ活用してみてください。

完全なソースコード(VHScrollView.kt)

以下に VHScrollView.kt の完全なソースコードを掲載します。パッケージ名は各自のプロジェクトに合わせて変更してください。

package com.example.vhscrollview

import android.content.Context
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.widget.FrameLayout
import android.widget.OverScroller
import androidx.core.view.GestureDetectorCompat
import androidx.core.view.ViewCompat

/**
 * 縦・横・斜めに自由にスクロールできるカスタム ViewGroup。
 *
 * 設計方針:
 * - [FrameLayout] を継承し、直下の子ビューを1つだけ持つ([android.widget.ScrollView] と同じ制約)。
 * - [GestureDetectorCompat] に `MotionEvent` の解析を委譲し、
 *   `onScroll`(ドラッグ移動量)と `onFling`(弾き初速)コールバックだけを処理する。
 * - [OverScroller] に X/Y の速度と限界値を渡し、OS 標準の慣性スクロール計算を利用する。
 */
class VHScrollView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {

    private val scroller = OverScroller(context)

    /**
     * onInterceptTouchEvent でドラッグを検知するための専用 GestureDetector。
     * onScroll が呼ばれた = スクロール意図あり → インターセプトフラグを立てる。
     */
    private var isScrolling = false
    private val interceptDetector = GestureDetectorCompat(
        context,
        object : GestureDetector.SimpleOnGestureListener() {
            override fun onDown(e: MotionEvent): Boolean = true
            override fun onScroll(
                e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float
            ): Boolean {
                isScrolling = true
                return true
            }
        }
    )

    private val gestureDetector = GestureDetectorCompat(
        context,
        object : GestureDetector.SimpleOnGestureListener() {

            /**
             * タッチダウン時にフリングアニメーションを止める。
             * `onScroll` / `onFling` を受け取るには true を返す必要がある。
             */
            override fun onDown(e: MotionEvent): Boolean {
                if (!scroller.isFinished) {
                    scroller.abortAnimation()
                }
                return true
            }

            /**
             * ドラッグ中に呼ばれる。指の移動量(distanceX, distanceY)をそのままスクロールに反映する。
             */
            override fun onScroll(
                e1: MotionEvent?,
                e2: MotionEvent,
                distanceX: Float,
                distanceY: Float
            ): Boolean {
                doScrollBy(distanceX.toInt(), distanceY.toInt())
                return true
            }

            /**
             * 指を弾いた時に呼ばれる。フリングの初速を [OverScroller] に渡し、慣性計算を開始する。
             * GestureDetector が返す velocityX/Y は「指の速度」なので、スクロール方向は反転する。
             */
            override fun onFling(
                e1: MotionEvent?,
                e2: MotionEvent,
                velocityX: Float,
                velocityY: Float
            ): Boolean {
                doFling(-velocityX.toInt(), -velocityY.toInt())
                return true
            }
        }
    )

    // -------------------------------------------------------------------------
    // Measure
    // -------------------------------------------------------------------------

    /**
     * 子ビューを [MeasureSpec.UNSPECIFIED] で計測する。
     * これにより、親の画面サイズを超えた大きな子ビューも本来のサイズで計測される。
     */
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        if (childCount == 0) return

        val child = getChildAt(0)
        val unspecified = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
        child.measure(unspecified, unspecified)
    }

    // -------------------------------------------------------------------------
    // Intercept
    // -------------------------------------------------------------------------

    /**
     * 子Viewよりも先にタッチイベントを確認し、ドラッグと判断したらインターセプト(横取り)する。
     * インターセプト後は以降のイベントが [onTouchEvent] に直接届くようになる。
     */
    override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
        // ACTION_CANCEL / ACTION_UP でフラグをリセット
        if (ev.action == MotionEvent.ACTION_CANCEL || ev.action == MotionEvent.ACTION_UP) {
            isScrolling = false
        }
        interceptDetector.onTouchEvent(ev)
        return isScrolling
    }

    // -------------------------------------------------------------------------
    // Touch
    // -------------------------------------------------------------------------

    override fun onTouchEvent(event: MotionEvent): Boolean {
        return gestureDetector.onTouchEvent(event) || super.onTouchEvent(event)
    }

    // -------------------------------------------------------------------------
    // Scroll
    // -------------------------------------------------------------------------

    /**
     * 現在位置に [dx], [dy] を加算してスクロール位置を更新する。
     * 0 〜 maxScroll の範囲にクランプすることで画面端を超えないようにする。
     */
    private fun doScrollBy(dx: Int, dy: Int) {
        val child = getChildAt(0) ?: return
        val maxX = (child.measuredWidth - width).coerceAtLeast(0)
        val maxY = (child.measuredHeight - height).coerceAtLeast(0)
        scrollTo(
            (scrollX + dx).coerceIn(0, maxX),
            (scrollY + dy).coerceIn(0, maxY)
        )
    }

    /**
     * [OverScroller.fling] に X/Y の初速と限界値を設定し、慣性スクロールを開始する。
     * 実際の座標反映は [computeScroll] で毎フレーム行う。
     */
    private fun doFling(velocityX: Int, velocityY: Int) {
        val child = getChildAt(0) ?: return
        val maxX = (child.measuredWidth - width).coerceAtLeast(0)
        val maxY = (child.measuredHeight - height).coerceAtLeast(0)

        scroller.fling(
            scrollX, scrollY,
            velocityX, velocityY,
            0, maxX,
            0, maxY
        )
        ViewCompat.postInvalidateOnAnimation(this)
    }

    // -------------------------------------------------------------------------
    // Animation loop
    // -------------------------------------------------------------------------

    /**
     * [ViewCompat.postInvalidateOnAnimation] によるフレームごとに呼ばれる。
     * [OverScroller] が計算した現在座標を取り出し、[scrollTo] で位置を確定する。
     * アニメーションが続く間は次フレームの描画を再予約する。
     */
    override fun computeScroll() {
        super.computeScroll()
        if (!scroller.computeScrollOffset()) return

        val child = getChildAt(0)
        val maxX = child?.let { (it.measuredWidth - width).coerceAtLeast(0) } ?: 0
        val maxY = child?.let { (it.measuredHeight - height).coerceAtLeast(0) } ?: 0

        scrollTo(
            scroller.currX.coerceIn(0, maxX),
            scroller.currY.coerceIn(0, maxY)
        )
        ViewCompat.postInvalidateOnAnimation(this)
    }
}

セキュアなAI環境を構築!ローカルLLM「Ollama」と「Open WebUI」の導入手順と社内活用

セキュアなAI環境を構築!ローカルLLM「Ollama」と「Open WebUI」の導入手順と社内活用

はじめに

近年、AI技術の発展によりLLM(大規模言語モデル)のビジネス活用が急速に進んでいます。しかし、クラウド型AIサービスを使用する際、社内データや顧客情報などの機密データを外部に送信することにセキュリティ上の懸念を抱く企業も少なくありません。そこで注目されているのが、社内のローカル環境でLLMを動かす「ローカルLLM」という選択肢です。本記事では、ローカルLLMを手軽に実行できるツール「Ollama」と、ChatGPTライクな直感的なUIを提供する「Open WebUI」を用いたプライバシー保護に優れたAI環境の導入手順と、データ解析での社内活用方法について詳しく解説します。

基礎知識・概要

Key Concept

ローカルLLMを活用することで、機密データを外部に出さずにセキュアなAI環境が構築可能です。OllamaとOpen WebUIの組み合わせにより、専門知識がなくてもスムーズに高機能な社内専用AIチャットツールを導入できます。

ローカルLLMとは、自社で用意したPCやサーバー上で動作する言語モデルを指します。外部のクラウドAPIと通信しないため、データのプライバシー保護やセキュリティの観点で非常に優れています。また、OllamaはMac、Windows、LinuxなどでローカルLLMを簡単にセットアップ・実行できるオープンソースツールです。操作の基本はコマンドラインですが、ここに「Open WebUI」を組み合わせることで、ブラウザから直感的なグラフィカルインターフェースでAIモデルにアクセスできるようになり、非エンジニアの従業員でも簡単にAIを活用できるようになります。

主要機能と詳細

Ollamaの強力なモデル管理機能

Ollamaの最大の魅力は、そのシンプルなモデル管理にあります。ollama run llama3といった直感的なコマンド一つで、MetaのLlama 3やGoogleのGemmaなど、強力なオープンソースLLMを自動的にダウンロードし、実行環境を構築してくれます。複雑なPythonの環境構築やGPUドライバーの依存関係に悩まされる時間を大幅に削減できる点が、多くのエンジニアに支持されています。

Open WebUIによるユーザビリティの向上

Open WebUIは、Ollamaなどのバックエンドとシームレスに連携するフロントエンドツールです。チャット履歴の保存、システムプロンプトのカスタマイズ、画像認識(マルチモーダル対応)など、クラウド型AIと同等の機能を無料で提供します。また、マルチユーザー機能も備えており、チームメンバーごとにアクセス権を管理できるため、セキュアな社内展開に最適な設計となっています。

実装・実践ガイド (導入手順)

ここでは、ローカル環境にOllamaとOpen WebUIを構築する具体的な手順を解説します。最も推奨されるアプローチは、Dockerを利用して環境を分離・管理する方法です。

ステップ1: Ollamaのインストール

まず、Ollama本体をインストールします。公式サイトからインストーラーをダウンロードするか、Linux/Macであれば以下のコマンドで簡単にインストールが可能です。

curl -fsSL https://ollama.com/install.sh | sh

ステップ2: モデルのダウンロードと起動

インストールが完了したら、使用したいLLMをダウンロードします。今回は汎用性が高いLlama 3を選択します。

ollama run llama3

ステップ3: Open WebUIの立ち上げ(Docker)

次に、Open WebUIをDocker経由で起動します。Ollamaがローカルホストで動いている場合、以下のコマンドを実行するだけでWebサーバーが立ち上がります。

docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:main

ブラウザで http://localhost:3000 にアクセスしアカウントを作成すれば、セキュアなAIチャット環境の完成です。機密性の高い文書データを読み込ませて、安全なデータ解析をスタートしましょう。

よくある課題と解決策

Warning

ローカル環境のスペック不足による生成速度の低下に注意してください。

メモリ・VRAMの要件

LLMをローカルで動かす際、最もよく直面するハードルがハードウェアの要件不足です。数十億パラメータのモデルを動かすには大容量のメモリ(またはGPUのVRAM)が必要になります。応答が遅い場合は、より小さなサイズのモデルを選択するか、量子化された軽量版モデルを利用することで、一般的なPCでも快適に動作させることができます。

セキュリティ設定とアクセス制御

社内ネットワークでOpen WebUIを公開する場合は、適切なアクセス制御が必須です。Open WebUIの管理画面からユーザーの新規登録を制限したり、リバースプロキシを挟んでSSL化やIP制限をかけることで、より強固なプライバシー保護と監視体制を実現しましょう。

まとめ

本記事では、「Ollama」と「Open WebUI」を活用したセキュアなローカルLLM環境の導入フローについて解説しました。クラウドAIに依存せず、すべての処理を自社内で完結させることで、機密データを伴うデータ解析など、これまでセキュリティの懸念から踏み切れなかった領域でのAI活用が可能になります。

オープンソースLLMの性能は日進月歩で進化しており、ローカル環境でも実務に十分耐えうる品質の回答が得られるようになっています。まずは手軽な環境からテスト導入し、自社の業務フローに合わせたセキュアなAI基盤を構築してみてはいかがでしょうか。

このブログ記事はAIを利用して自動生成されました。

Flashコンテンツの再構築

Flashコンテンツの再構築は、単なる懐古趣味にとどまりません。AIを用いてレガシー資産を現代化する高度な技術実証の場となっています。かつてActionScript 2や3で記述されたロジックを、HTML5やWebAssembly、TypeScriptといった現代の環境へ移植するために、さまざまなAI技術が投入されています。

中心となるのは、LLMによるコード変換です。これは単なる文字列の置換ではなく、文脈を汲み取ったセマンティックな変換を指します。現代的な非同期処理やクラス構造への最適化を施しながら、すでに廃止されたFlash独自の描画メソッドなどを、PixiJSやCreateJSといった代替ライブラリへ自動的にマッピングしていきます。

視覚面では、GAN(敵対的生成ネットワーク)を用いたアセットの高解像度化が大きな役割を果たします。かつてのSWFファイルに含まれていた低解像度のベクターデータやビットマップデータを、4K環境でも耐えうる画質へアップスケーリングします。さらに、Ruffleなどの既存エミュレータでは再現しきれない複雑な物理演算や通信処理をAIが解析し、現代のコードで再実装することで、エミュレーションの不足を補完します。

こうした過去の知的財産を再利用することは、企業にとって大きな戦略的価値を持ちます。ゼロからリメイクするのではなく、既存のロジックや素材をAIで変換することで、工期を従来の3割から5割程度にまで短縮し、開発コストを劇的に抑えられます。これは2000年代にファンだった現在の30代から40代への訴求につながるだけでなく、軽量なWebゲームとして再リリースすることで、新規ユーザーとの接点を創出する機会にもなります。ブラウザだけでなく、モバイルアプリやクラウドゲームといったプラットフォームの垣根を越えた展開も容易になります。

実装にあたっては、ソースコードの紛失や権利関係の整理といった課題が伴います。しかし、コンパイル済みのSWFからAIが逆コンパイルを行い、難読化された変数名を文脈から推測して復元することが可能です。外部ライブラリの依存関係をスキャンしてライセンスの競合を特定したり、フレーム補完によって60FPSを実現したり、タッチパネル操作へUIを最適化したりといった対応もAIが担います。

今後は単なる復元を超え、AIが当時のゲーム性を拡張する段階へ移行していくでしょう。当時のステージ構成を学習して新しいステージを無限に生成したり、ユーザーの操作ログをリアルタイムで解析して難易度を動的に調整したりといった展開が期待されます。

Flashゲームの再構築は、AIが過去の技術的負債を現代の資産へと昇華させる象徴的な事例です。かつての資産を最新技術で甦らせる試みは、技術の継承とイノベーションを両立させる大きな一歩となります。