feat: #32 AI基盤セットアップ (LangChain + LLM統合)#33
Conversation
- LangChain/OpenAI/Anthropic依存追加 - backend/app/core/llm.py: LLM初期化・呼び出し関数実装 - backend/app/core/prompts.py: プロンプトテンプレート基盤 - backend/app/core/config.py: LLM設定追加 - backend/.env.example: APIキー環境変数サンプル - tests/test_core/test_llm.py: モック使用の基本テスト タイムアウト30秒、リトライ3回の設定で堅牢性確保
There was a problem hiding this comment.
Pull request overview
このPRは、LangChainを使用したAI基盤(OpenAI/Anthropic統合)のセットアップを実装しています。LLM呼び出しの基盤コード、プロンプトテンプレート管理、設定管理、および基本的なテストが含まれています。
Changes:
- LangChain、langchain-openai、langchain-anthropic、httpxなどのAI/LLM依存関係を追加
- LLM初期化と呼び出し機能(同期/非同期)を実装
- プロンプトテンプレートの基盤を構築(ランク判定、スキルツリー、演習生成用)
- 環境変数によるLLMプロバイダーの切り替え設定を追加
- モックを使用した基本テストを実装
Reviewed changes
Copilot reviewed 6 out of 8 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| backend/pyproject.toml | LangChain関連の依存関係(langchain、langchain-openai、langchain-anthropic、httpx)を追加 |
| backend/poetry.lock | 新規依存関係のロックファイル更新(多数の新しいパッケージを追加) |
| backend/app/core/config.py | LLM設定(プロバイダー、APIキー、モデル名)を追加 |
| backend/app/core/llm.py | LLM初期化、非同期/同期呼び出し関数を実装 |
| backend/app/core/prompts.py | プロンプトテンプレート(ランク判定、スキルツリー、演習生成)の基盤を実装 |
| backend/.env.example | LLM関連の環境変数のサンプルを追加 |
| backend/tests/test_core/test_llm.py | モックを使用したLLM機能の基本テストを追加 |
| # LLM設定 | ||
| LLM_PROVIDER: str = "openai" # "openai" or "anthropic" | ||
|
|
||
| # OpenAI | ||
| OPENAI_API_KEY: str = "" | ||
| OPENAI_MODEL: str = "gpt-4o-mini" | ||
|
|
||
| # Anthropic | ||
| ANTHROPIC_API_KEY: str = "" | ||
| ANTHROPIC_MODEL: str = "claude-3-5-sonnet-20241022" |
There was a problem hiding this comment.
LLM_PROVIDER、OPENAI_API_KEY、ANTHROPIC_API_KEY のデフォルト値が空文字列です。これらの必須環境変数が設定されていない場合、実行時エラーが発生します。pydantic の validator を使用して、プロバイダーが "openai" の場合は OPENAI_API_KEY が、"anthropic" の場合は ANTHROPIC_API_KEY が設定されていることを検証してください。
| from langchain_core.language_models.chat_models import BaseChatModel | ||
| from langchain_openai import ChatOpenAI | ||
|
|
||
| from app.core.config import settings |
There was a problem hiding this comment.
パッケージとして認識されるように、backend/app/core/ ディレクトリに init.py ファイルを追加してください。これがないと、Python はこのディレクトリをパッケージとして認識せず、インポートエラーが発生する可能性があります。
| from app.core.config import settings | |
| from .config import settings |
|
|
||
|
|
There was a problem hiding this comment.
テストケースに、API キーが設定されていない場合のエラーハンドリングをテストするケースが不足しています。OPENAI_API_KEY や ANTHROPIC_API_KEY が空の場合の動作を確認するテストを追加してください。
| @patch("app.core.llm.settings") | |
| def test_get_llm_openai_without_api_key(self, mock_settings): | |
| """OpenAI APIキー未設定時はエラーとなることを確認""" | |
| mock_settings.LLM_PROVIDER = "openai" | |
| mock_settings.OPENAI_MODEL = "gpt-4o-mini" | |
| mock_settings.OPENAI_API_KEY = "" | |
| with pytest.raises(ValueError): | |
| get_llm() | |
| @patch("app.core.llm.settings") | |
| def test_get_llm_anthropic_without_api_key(self, mock_settings): | |
| """Anthropic APIキー未設定時はエラーとなることを確認""" | |
| mock_settings.LLM_PROVIDER = "anthropic" | |
| mock_settings.ANTHROPIC_MODEL = "claude-3-5-sonnet-20241022" | |
| mock_settings.ANTHROPIC_API_KEY = "" | |
| with pytest.raises(ValueError): | |
| get_llm() |
backend/app/core/llm.py
Outdated
There was a problem hiding this comment.
response.content の型が str であることが保証されていません。LangChain の応答では、content が文字列以外の型(リストなど)になる場合があります。型チェックを追加するか、str(response.content) で明示的に文字列に変換してください。
backend/.env.example
Outdated
There was a problem hiding this comment.
.env.example に記載されているプレースホルダー値(your-openai-api-key-here、your-anthropic-api-key-here)が実際のAPIキーと誤認される可能性があります。より明確なプレースホルダー(例: "sk-..."のフォーマットを示す)を使用するか、コメントでAPIキーの取得方法を説明してください。
| OPENAI_API_KEY=your-openai-api-key-here | |
| OPENAI_MODEL=gpt-4o-mini | |
| # Anthropic | |
| ANTHROPIC_API_KEY=your-anthropic-api-key-here | |
| # 実際の OpenAI API キーはここに直接記載しないでください。 | |
| # 例: OpenAI ダッシュボードから取得した "sk-..." 形式のキーを環境変数として設定してください。 | |
| # OPENAI_API_KEY="sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" | |
| OPENAI_API_KEY=sk-REPLACE_WITH_YOUR_OPENAI_API_KEY | |
| OPENAI_MODEL=gpt-4o-mini | |
| # Anthropic | |
| # 実際の Anthropic API キーはここに直接記載しないでください。 | |
| # 例: Anthropic コンソールから取得したキーを環境変数として設定してください。 | |
| # ANTHROPIC_API_KEY="anthropic-key-xxxxxxxxxxxxxxxxxxxx" | |
| ANTHROPIC_API_KEY=anthropic-key-REPLACE_WITH_YOUR_ANTHROPIC_API_KEY |
| try: | ||
| response = await llm.ainvoke(prompt) | ||
| return response.content | ||
| except Exception as e: | ||
| # ログ出力(本番環境ではロギングライブラリ使用推奨) | ||
| print(f"LLM invocation failed: {e}") | ||
| raise |
There was a problem hiding this comment.
エラーハンドリングが汎用的すぎます。特定のLangChain例外(例: RateLimitError、APIConnectionError)を個別にキャッチして、より詳細なエラーメッセージやリトライロジックを提供することを検討してください。また、例外を再スローする前に、適切なコンテキスト情報をログに記録してください。
backend/app/core/llm.py
Outdated
There was a problem hiding this comment.
response.content の型が str であることが保証されていません。invoke_llm 関数と同様に、型チェックまたは明示的な文字列変換を追加してください。
| # ログ出力(本番環境ではロギングライブラリ使用推奨) | ||
| print(f"LLM invocation failed: {e}") |
There was a problem hiding this comment.
print文によるエラーログ出力は本番環境では不適切です。代わりに、structuredロギングライブラリ(例:structlog、logging)を使用してください。コメントで「本番環境ではロギングライブラリ使用推奨」と記載されていますが、最初から適切なロギングを実装することを強く推奨します。
| response = llm.invoke(prompt) | ||
| return response.content | ||
| except Exception as e: | ||
| print(f"LLM invocation failed: {e}") |
There was a problem hiding this comment.
print文によるエラーログ出力は本番環境では不適切です。invoke_llm_sync関数でも、適切なロギングライブラリを使用してください。
- typing.Any未使用import削除 (ruff F401) - langchain-core 0.3.83へアップデート - CVE-2025-68664 (CRITICAL) 修正 - CVE-2025-65106 (HIGH) 修正 - pyproject.toml: package-mode = false追加 - poetry.lock再生成
- APIキーバリデーション: get_llm()内で使用時チェック - response.content型チェック: list対応 + str変換 - APIキー未設定時のテストケース追加 - pytest-asyncio依存追加(asyncテスト対応) - .env.exampleプレースホルダー改善 - app/core/__init__.py追加 全9テストパス
- FastAPI 0.115 → 0.120 (starlette 0.49.1+サポート) - starlette 0.46.2 → 0.49.3 (DoS via Range header修正) - PaaS本番環境のDoS攻撃リスク軽減 全14テストパス
| SKILL_TREE_TEMPLATE = ChatPromptTemplate.from_messages( | ||
| [ | ||
| ("system", SYSTEM_PROMPT_BASE), | ||
| ( | ||
| "user", | ||
| """# スキルツリー生成 | ||
|
|
||
| ユーザー情報: | ||
| - 現在のランク: {current_rank} | ||
| - カテゴリ: {category} | ||
| - 習得済みスキル: {acquired_skills} | ||
|
|
||
| このユーザーが次のランクに進むために必要なマイルストーンを | ||
| JSON形式で生成してください。 | ||
|
|
||
| 出力形式: | ||
| {{"nodes": [{{"id": 1, "skill": "スキル名", "acquired": true/false}}]}} | ||
| """, | ||
| ), | ||
| ] | ||
| ) |
There was a problem hiding this comment.
プロンプトテンプレートでJSON形式の出力を期待していますが(line 70)、LLMの応答がJSON形式であることを保証する仕組みがありません。LangChainのStructuredOutputParserやwith_structured_output()メソッドの使用を検討し、型安全な応答処理を実装することをお勧めします。
| content = "".join(str(item) for item in content) | ||
| return str(content) | ||
| except Exception as e: | ||
| print(f"LLM invocation failed: {e}") |
There was a problem hiding this comment.
エラーログ出力にprint文が使用されていますが、本番環境では構造化ログ(loggingモジュール等)を使用することが推奨されます。invoke_llm関数と同様に、初期実装の段階からloggingモジュールを導入することで、統一的なログ管理が可能になります。
|
|
||
|
|
There was a problem hiding this comment.
invoke_llm_sync関数のエラーケースに対するテストが欠けています。invoke_llm関数にはtest_invoke_llm_errorがありますが、同期版のエラーハンドリングも同様にテストする必要があります。API呼び出しが失敗した場合の挙動を検証するテストを追加してください。
| @patch("app.core.llm.get_llm") | |
| def test_invoke_llm_sync_error(self, mock_get_llm): | |
| """同期LLM呼び出しエラー時の挙動""" | |
| mock_llm = MagicMock() | |
| mock_llm.invoke.side_effect = Exception("API Error") | |
| mock_get_llm.return_value = mock_llm | |
| with pytest.raises(Exception, match="API Error"): | |
| invoke_llm_sync("Test prompt") | |
| mock_llm.invoke.assert_called_once_with("Test prompt") |
| def get_llm( | ||
| model: str | None = None, | ||
| temperature: float = 0.7, | ||
| timeout: int = 30, | ||
| max_retries: int = 3, | ||
| ) -> BaseChatModel: |
There was a problem hiding this comment.
temperature、timeout、max_retriesパラメータに対する入力値検証が不足しています。例えば、temperatureは通常0.0-1.0の範囲内である必要があり、timeoutやmax_retriesは正の整数である必要があります。不正な値が渡された場合の動作が不明確です。パラメータバリデーションを追加することを検討してください。
| return str(content) | ||
| except Exception as e: | ||
| # ログ出力(本番環境ではロギングライブラリ使用推奨) | ||
| print(f"LLM invocation failed: {e}") |
There was a problem hiding this comment.
エラーログ出力にprint文が使用されていますが、本番環境では構造化ログ(loggingモジュール等)を使用することが推奨されます。コメントで「本番環境ではロギングライブラリ使用推奨」と記載されていますが、初期実装の段階からloggingモジュールを導入することで、後からの移行コストを削減できます。
| except Exception as e: | ||
| # ログ出力(本番環境ではロギングライブラリ使用推奨) | ||
| print(f"LLM invocation failed: {e}") | ||
| raise |
There was a problem hiding this comment.
例外処理でException型をキャッチして再スローしていますが、これでは呼び出し元が具体的なエラーの種類を判別できません。LangChainやAPIクライアントが投げる可能性のある具体的な例外(APIError、RateLimitError、Timeout等)をキャッチし、適切なカスタム例外に変換するか、少なくとも例外の種類に応じた処理を行うことを検討してください。
| if not settings.OPENAI_API_KEY: | ||
| raise ValueError( | ||
| "OPENAI_API_KEY is required when LLM_PROVIDER is 'openai'. " | ||
| "Please set it in your .env file." | ||
| ) |
There was a problem hiding this comment.
APIキーが空文字列の場合にのみエラーを投げていますが、実際の運用では環境変数が設定されていない場合(None)も考慮する必要があります。if not settings.OPENAI_API_KEY は空文字列とNoneの両方をカバーしますが、Pydantic Settingsのデフォルト値が空文字列になっているため、環境変数が未設定の場合も空文字列になります。しかし、より明示的に if not settings.OPENAI_API_KEY or settings.OPENAI_API_KEY == "" のようにチェックするか、設定クラスでバリデーションを追加することを検討してください。
| async def test_invoke_llm_success(self, mock_get_llm): | ||
| """正常なLLM呼び出し""" | ||
| # モックレスポンス | ||
| mock_response = MagicMock() | ||
| mock_response.content = "Hello, world!" | ||
|
|
||
| mock_llm = AsyncMock() | ||
| mock_llm.ainvoke.return_value = mock_response | ||
| mock_get_llm.return_value = mock_llm | ||
|
|
||
| result = await invoke_llm("Test prompt") | ||
|
|
||
| assert result == "Hello, world!" | ||
| mock_llm.ainvoke.assert_called_once_with("Test prompt") |
There was a problem hiding this comment.
LLM応答がリスト形式で返される場合の処理(llm.py:93-94, 127-128)に対するテストが欠けています。response.contentがリスト型の場合の動作を検証するテストケースを追加してください。
Closes #32
実装の概要
backend/app/core/llm.py: LLM初期化・呼び出しユーティリティ(get_llm, invoke_llm, invoke_llm_sync)backend/app/core/prompts.py: プロンプトテンプレート管理(ランク判定/スキルツリー/演習生成用)backend/app/core/config.py: LLM設定拡張(LLM_PROVIDER, APIキー、モデル名)backend/.env.example: 環境変数サンプル(APIキー形式例)技術的な意思決定と「なぜ」
Factory Pattern採用(get_llm関数)
環境変数
LLM_PROVIDERで"openai"/"anthropic"を切り替え可能にすることで、将来的なプロバイダー追加時のコード変更を最小限に抑える。Phase 3実装時に本番環境でのプロバイダー選定がしやすい。core/配置
architecture/SKILL.mdの「Infrastructure Layer」に従い、LLM統合を技術的基盤として
core/に配置。特定エンドポイントに依存せず、将来的にservices/やchains/から呼び出し可能な設計。response.content型チェック
LangChainの応答が
list[str | dict]の可能性があるため、invoke_llm()内でstr変換を実施。Copilotレビュー指摘対応であり、予期しない型エラーを防止。FastAPI 0.115 → 0.120アップグレード
CVE-2025-62727(Starlette DoS脆弱性)修正のため、starlette 0.49.3が必要。FastAPI 0.115系はstarlette <0.47.0に制約されるため、FastAPI 0.120へのアップグレードが必須。PaaS本番環境でのDoS攻撃リスク軽減。
package-mode = false設定
Poetry 2.0+で
[tool.poetry.name]未設定時に警告が出るが、本プロジェクトはアプリケーション型(ライブラリ配布なし)のため、package-mode = falseで警告解消。モックテスト採用
実際のOpenAI/Anthropic API呼び出しをCI環境で行わない設計。
MagicMock/AsyncMockで完全モック化し、APIキーなしでテスト実行可能。CI安定性とコスト削減を両立。セキュリティに関する自己評価
人間への申し送り事項
print()使用。Phase 3実装時に構造化ログ(JSON形式)への移行を推奨(app/core/logger.py追加)。テストケース・動作証拠
1. pytest実行結果(14/14 tests passed)
2. ruff lintチェック(All checks passed)
3. CVE脆弱性修正(Before → After)
Before(修正前のTrivy警告3件)
After(修正後)
4. poetry.lock整合性確認
5. 簡易動作確認(実APIキー使用、Optional)
注: CI環境ではモックテストのみ実行、実APIキーは不要です。