Skip to content

Commit c1680e0

Browse files
feat(issue-57): ドキュメントからハンズオン演習を自動生成するAI機能の実装
## 概要 ユーザーが学びたい技術のドキュメントを入力すると、 LLMを使用してランクに見合った実践的なハンズオン演習を自動生成する機能を実装。 概念実証を優先し、まずは動くことを重視。 ## 実装内容 ### 1. プロンプトテンプレート拡張 - `app/core/prompts.py`: QUEST_GENERATION_TEMPLATEを詳細化 - ランク別の難易度設定ガイドライン - JSON出力形式の厳密な指定 - ステップ構成の要件定義 ### 2. サービス層実装 - `app/services/quest_service.py`: generate_handson_quest関数 - LLM呼び出し (temperature=0.7で創造性を持たせる) - JSONパース+エラーハンドリング - デフォルト値フォールバック ### 3. スキーマ定義拡張 - `app/schemas/quest.py`: QuestGenerationRequest/Response追加 - QuestStep: 演習ステップの詳細定義 - リクエストバリデーション (document_content: 10KB制限) - Swagger UIのExample付き ### 4. APIエンドポイント実装 - `app/api/endpoints/quest.py`: POST /api/v1/quest/generate - リクエスト受け取り - サービス呼び出し - エラーハンドリング ### 5. ルーター登録 - `app/api/api.py`: questルーターを統合 ### 6. テスト実装 - `tests/test_services/test_quest_service.py`: サービス層テスト - `tests/test_api/test_quest.py`: APIエンドポイントテスト - LLM呼び出しをモック化してCI環境対応 - 正常系・エラー系・バリデーションをカバー ## MVP制限(概念実証優先) - ❌ DB保存なし: 生成された演習はAPIレスポンスのみ - ❌ 進捗管理なし: 演習の完了状態やスコアは後続Issue - ⚠️ 生成品質: プロンプト調整は後続Issue ## セキュリティ考慮 - Request Bodyサイズ制限(document_content: 10KB) - LLMレスポンスのサニタイズ(JSONパースエラー時のフォールバック) - エラーメッセージの安全な返却 ## テスト要件 - モック使用でAPI キー不要(CI環境対応) - 正常系・エラー系・バリデーションを網羅 ## 完了条件(DoD) - [x] POST /api/v1/quest/generate実装完了 - [x] スキーマ定義とバリデーション実装 - [x] テスト5件以上実装(モック使用) - [x] エラーハンドリング実装 ## 依存 - Issue #32(AI基盤セットアップ)✅ - Issue #36(ランク判定AI)✅ ## 次のステップ(後続Issue) - Issue #41: 演習のDB保存機能 - Issue #42: 演習進捗管理機能 - Issue #43: 演習生成品質向上 - Issue #44: 生成コードのセキュリティチェック
1 parent 48a0f75 commit c1680e0

File tree

7 files changed

+418
-24
lines changed

7 files changed

+418
-24
lines changed

backend/app/api/api.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
"""ルーター集約"""
22

33
from fastapi import APIRouter
4-
from app.api.endpoints import analyze
5-
4+
from app.api.endpoints import analyze, quest
65

76
api_router = APIRouter()
87

98
api_router.include_router(analyze.router, prefix="/analyze", tags=["analyze"])
9+
api_router.include_router(quest.router, prefix="/quest", tags=["quest"])

backend/app/api/endpoints/quest.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""
2+
ハンズオン演習生成APIエンドポイント
3+
4+
POST /api/v1/quest/generate - ドキュメントからハンズオン演習を生成
5+
"""
6+
7+
from fastapi import APIRouter, HTTPException
8+
from app.schemas.quest import QuestGenerationRequest, QuestGenerationResponse
9+
from app.services.quest_service import generate_handson_quest
10+
11+
router = APIRouter()
12+
13+
14+
@router.post("/generate", response_model=QuestGenerationResponse)
15+
async def generate_quest(request: QuestGenerationRequest) -> QuestGenerationResponse:
16+
"""
17+
ドキュメントからハンズオン演習を生成
18+
19+
Args:
20+
request: ハンズオン生成リクエスト
21+
22+
Returns:
23+
QuestGenerationResponse: 生成された演習
24+
25+
Raises:
26+
HTTPException 500: LLM呼び出し失敗時
27+
28+
Example:
29+
Request:
30+
{
31+
"document_content": "Reactの基本: コンポーネント、State、Props...",
32+
"user_rank": 2,
33+
"user_skills": "JavaScript, HTML/CSS"
34+
}
35+
36+
Response:
37+
{
38+
"title": "Reactでカウンターアプリを作ろう",
39+
"difficulty": "beginner",
40+
"estimated_time_minutes": 45,
41+
"learning_objectives": ["Stateの理解", "イベントハンドリング"],
42+
"steps": [...],
43+
"resources": ["https://react.dev/"]
44+
}
45+
"""
46+
try:
47+
result = await generate_handson_quest(
48+
document_content=request.document_content,
49+
user_rank=request.user_rank,
50+
user_skills=request.user_skills,
51+
)
52+
return QuestGenerationResponse(**result)
53+
except Exception as e:
54+
raise HTTPException(status_code=500, detail=f"Quest generation failed: {str(e)}")

backend/app/core/prompts.py

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -93,36 +93,47 @@
9393

9494

9595
# ============================================================
96-
# 演習生成用プロンプト(Phase 3で詳細実装)
96+
# 演習生成用プロンプト
9797
# ============================================================
9898

99-
QUEST_GENERATION_TEMPLATE = ChatPromptTemplate.from_messages(
100-
[
101-
("system", SYSTEM_PROMPT_BASE),
102-
(
103-
"user",
104-
"""# ハンズオン演習生成
99+
QUEST_GENERATION_TEMPLATE = """あなたは優秀な技術教育者です。
100+
以下のドキュメント内容を元に、ユーザーのランクに適したハンズオン演習を生成してください。
105101
106-
ドキュメント内容:
102+
## ドキュメント内容
107103
{document_content}
108104
109-
対象ユーザー:
110-
- ランク: {user_rank}
105+
## 対象ユーザー
106+
- ランク: {user_rank} (0: 種子, 1: 苗木, ..., 9: 世界樹)
111107
- 得意分野: {user_skills}
112108
113-
このドキュメントの内容を学ぶための、実践的なハンズオン演習を生成してください。
109+
## 生成要件
110+
1. ユーザーのランクに適した難易度設定
111+
- ランク0-2: 基礎的な手順、丁寧な説明
112+
- ランク3-5: 中級者向け、応用要素を含む
113+
- ランク6-9: 高度な実装、最適化やアーキテクチャ設計
114+
2. 段階的な手順(5-10ステップ)
115+
3. 各ステップは実行可能で具体的
116+
4. 学習効果が高く、実務に役立つ内容
114117
115-
## 要件
116-
- ユーザーのランクに適した難易度
117-
- 段階的な手順(5-10ステップ)
118-
- 実行可能な具体的なタスク
119-
- 学習効果の高い内容
118+
## 出力形式(JSON)
119+
{{
120+
"title": "演習タイトル",
121+
"difficulty": "beginner|intermediate|advanced",
122+
"estimated_time_minutes": 60,
123+
"learning_objectives": ["目標1", "目標2", "目標3"],
124+
"steps": [
125+
{{
126+
"step_number": 1,
127+
"title": "ステップタイトル",
128+
"description": "具体的な手順の説明",
129+
"code_example": "コード例(必要に応じて)",
130+
"checkpoints": ["確認ポイント1", "確認ポイント2"]
131+
}}
132+
],
133+
"resources": ["参考リンク1", "参考リンク2"]
134+
}}
120135
121-
出力形式: Markdown
122-
""",
123-
),
124-
]
125-
)
136+
JSON以外の形式や追加の説明は含めず、JSONのみを出力してください。"""
126137

127138

128139
# ============================================================

backend/app/schemas/quest.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from datetime import datetime
44

5-
from pydantic import BaseModel, ConfigDict, field_validator
5+
from pydantic import BaseModel, ConfigDict, Field, field_validator
66

77
from app.models.enums import QuestCategory
88

@@ -32,3 +32,71 @@ class Quest(BaseModel):
3232
created_at: datetime
3333

3434
model_config = ConfigDict(from_attributes=True)
35+
36+
37+
# ============================================================
38+
# ハンズオン演習生成用スキーマ
39+
# ============================================================
40+
41+
42+
class QuestStep(BaseModel):
43+
"""演習ステップ"""
44+
45+
step_number: int = Field(..., ge=1, description="ステップ番号")
46+
title: str = Field(..., min_length=1, max_length=200, description="ステップタイトル")
47+
description: str = Field(..., description="手順の詳細説明")
48+
code_example: str = Field(default="", description="コード例")
49+
checkpoints: list[str] = Field(default_factory=list, description="確認ポイント")
50+
51+
52+
class QuestGenerationRequest(BaseModel):
53+
"""ハンズオン生成リクエスト"""
54+
55+
model_config = ConfigDict(
56+
json_schema_extra={
57+
"example": {
58+
"document_content": "Reactの基本: コンポーネント、State、Props...",
59+
"user_rank": 2,
60+
"user_skills": "JavaScript, HTML/CSS",
61+
}
62+
}
63+
)
64+
65+
document_content: str = Field(
66+
..., min_length=10, max_length=10000, description="学習対象ドキュメント"
67+
)
68+
user_rank: int = Field(..., ge=0, le=9, description="ユーザーランク")
69+
user_skills: str = Field(default="", max_length=500, description="得意分野(オプション)")
70+
71+
72+
class QuestGenerationResponse(BaseModel):
73+
"""ハンズオン生成レスポンス"""
74+
75+
model_config = ConfigDict(
76+
json_schema_extra={
77+
"example": {
78+
"title": "Reactでカウンターアプリを作ろう",
79+
"difficulty": "beginner",
80+
"estimated_time_minutes": 45,
81+
"learning_objectives": ["Stateの理解", "イベントハンドリング"],
82+
"steps": [
83+
{
84+
"step_number": 1,
85+
"title": "プロジェクトのセットアップ",
86+
"description": "create-react-appで新規プロジェクトを作成",
87+
"code_example": "npx create-react-app counter-app",
88+
"checkpoints": ["プロジェクトが起動した"],
89+
}
90+
],
91+
"resources": ["https://react.dev/"],
92+
}
93+
}
94+
)
95+
96+
title: str = Field(..., description="演習タイトル")
97+
difficulty: str = Field(..., description="難易度(beginner/intermediate/advanced)")
98+
estimated_time_minutes: int = Field(..., ge=1, description="推定所要時間(分)")
99+
learning_objectives: list[str] = Field(..., description="学習目標")
100+
steps: list[QuestStep] = Field(..., min_length=1, description="演習ステップ")
101+
resources: list[str] = Field(default_factory=list, description="参考リソース")
102+
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""
2+
ハンズオン演習生成サービス
3+
4+
LLMを使用してドキュメントからハンズオン演習を生成するビジネスロジック
5+
"""
6+
7+
import json
8+
from app.core.llm import invoke_llm
9+
from app.core.prompts import QUEST_GENERATION_TEMPLATE
10+
11+
12+
async def generate_handson_quest(
13+
document_content: str,
14+
user_rank: int,
15+
user_skills: str = "",
16+
) -> dict:
17+
"""
18+
LLMを使用してハンズオン演習を生成
19+
20+
Args:
21+
document_content: 学習対象のドキュメント
22+
user_rank: ユーザーのランク(0-9)
23+
user_skills: ユーザーの得意分野(オプション)
24+
25+
Returns:
26+
{
27+
"title": str,
28+
"difficulty": str,
29+
"estimated_time_minutes": int,
30+
"learning_objectives": list[str],
31+
"steps": list[dict],
32+
"resources": list[str]
33+
}
34+
35+
Note:
36+
- JSONパースエラー時はデフォルト値を返す
37+
- 生成品質の向上は後続Issueで対応
38+
"""
39+
# プロンプトテンプレートに入力値を埋め込む
40+
prompt = QUEST_GENERATION_TEMPLATE.format(
41+
document_content=document_content,
42+
user_rank=user_rank,
43+
user_skills=user_skills or "未指定",
44+
)
45+
46+
# LLMに非同期で呼び出し (temperature=0.7で創造性を持たせる)
47+
response = await invoke_llm(prompt=prompt, temperature=0.7)
48+
49+
# JSONパース(エラーハンドリング付き)
50+
try:
51+
result = json.loads(response)
52+
# 必須フィールドの存在確認
53+
required_fields = ["title", "difficulty", "steps"]
54+
if not all(k in result for k in required_fields):
55+
raise ValueError("Missing required fields in LLM response")
56+
return result
57+
except (json.JSONDecodeError, ValueError) as e:
58+
# LLMがJSON以外を返した場合のフォールバック
59+
print(f"JSON parse error: {e}. Returning fallback response.")
60+
return {
61+
"title": "演習生成エラー",
62+
"difficulty": "beginner",
63+
"estimated_time_minutes": 30,
64+
"learning_objectives": ["ドキュメントの内容理解"],
65+
"steps": [
66+
{
67+
"step_number": 1,
68+
"title": "ドキュメントを読む",
69+
"description": "提供されたドキュメントを読んで理解を深めてください。",
70+
"code_example": "",
71+
"checkpoints": ["ドキュメントの概要を理解した"],
72+
}
73+
],
74+
"resources": [],
75+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""
2+
ハンズオン演習生成APIエンドポイントのテスト
3+
4+
FastAPIのテストクライアントを使用
5+
"""
6+
7+
import json
8+
import pytest
9+
from unittest.mock import AsyncMock, patch
10+
from fastapi.testclient import TestClient
11+
from app.main import app
12+
13+
client = TestClient(app)
14+
15+
16+
def test_generate_quest_success():
17+
"""正常系: POST /api/v1/quest/generate"""
18+
mock_response = {
19+
"title": "Pythonでウェブスクレイピング",
20+
"difficulty": "intermediate",
21+
"estimated_time_minutes": 60,
22+
"learning_objectives": ["Beautiful Soupの使い方"],
23+
"steps": [
24+
{
25+
"step_number": 1,
26+
"title": "ライブラリインストール",
27+
"description": "pip install beautifulsoup4",
28+
"code_example": "pip install beautifulsoup4 requests",
29+
"checkpoints": ["インストール完了"],
30+
}
31+
],
32+
"resources": [],
33+
}
34+
35+
with patch("app.services.quest_service.invoke_llm", new_callable=AsyncMock) as mock_invoke:
36+
mock_invoke.return_value = json.dumps(mock_response)
37+
38+
response = client.post(
39+
"/api/v1/quest/generate",
40+
json={
41+
"document_content": "Pythonでウェブスクレイピングを学ぶ...",
42+
"user_rank": 3,
43+
"user_skills": "Python基礎",
44+
},
45+
)
46+
47+
assert response.status_code == 200
48+
data = response.json()
49+
assert data["title"] == "Pythonでウェブスクレイピング"
50+
assert data["difficulty"] == "intermediate"
51+
52+
53+
def test_generate_quest_minimal():
54+
"""最小入力のテスト"""
55+
mock_response = {
56+
"title": "Docker入門",
57+
"difficulty": "beginner",
58+
"estimated_time_minutes": 40,
59+
"learning_objectives": ["Dockerの基本操作"],
60+
"steps": [
61+
{
62+
"step_number": 1,
63+
"title": "Dockerインストール",
64+
"description": "Docker Desktopをインストール",
65+
"code_example": "",
66+
"checkpoints": ["バージョン確認"],
67+
}
68+
],
69+
"resources": ["https://docs.docker.com/"],
70+
}
71+
72+
with patch("app.services.quest_service.invoke_llm", new_callable=AsyncMock) as mock_invoke:
73+
mock_invoke.return_value = json.dumps(mock_response)
74+
75+
response = client.post(
76+
"/api/v1/quest/generate",
77+
json={"document_content": "Dockerの基礎を学ぶチュートリアル", "user_rank": 1},
78+
)
79+
80+
assert response.status_code == 200
81+
data = response.json()
82+
assert data["title"] == "Docker入門"
83+
assert data["difficulty"] == "beginner"
84+
85+
86+
def test_generate_quest_invalid_rank():
87+
"""エラー系: ランクが範囲外は422エラー"""
88+
response = client.post(
89+
"/api/v1/quest/generate", json={"document_content": "Test", "user_rank": 99}
90+
)
91+
92+
assert response.status_code == 422
93+
94+
95+
def test_generate_quest_document_too_short():
96+
"""エラー系: ドキュメントが短すぎる場合は422エラー"""
97+
response = client.post(
98+
"/api/v1/quest/generate", json={"document_content": "Short", "user_rank": 2}
99+
)
100+
101+
assert response.status_code == 422

0 commit comments

Comments
 (0)