Skip to content

Commit 3b8c905

Browse files
authored
Merge pull request #76 from kc3hack/74-skill-tree-integration
feat: #74 スキルツリーのバックエンドAPI統合
2 parents 4695140 + f29ddea commit 3b8c905

File tree

25 files changed

+1607
-333
lines changed

25 files changed

+1607
-333
lines changed
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# 014 スキルツリーの座標自動レイアウト生成
2+
3+
## ステータス
4+
5+
- [x] **決定**
6+
7+
## コンテキスト (課題と背景)
8+
9+
Issue #74でスキルツリーのフロントエンド・バックエンドAPI統合を行う際、バックエンドから返されるノードデータをCanvas上にどう配置するかを決定する必要があった。
10+
11+
### 背景
12+
- バックエンドのJSONには`prerequisites`(前提スキル)の依存関係のみが存在し、X/Y座標情報は存在しない
13+
- 6カテゴリ(web/ai/security/infrastructure/game/design)それぞれに対応する必要がある
14+
- 将来的にカテゴリやスキルノード数が増減する可能性がある
15+
- ハッカソン期間のため、実装時間を最小化したい
16+
17+
## 決定 (Decision)
18+
19+
**APIデータから`prerequisites`の深さに基づいてtier(階層)を計算し、X/Y座標を自動生成する方式を採用**
20+
21+
### 実装方針
22+
- 前提スキルを持たないノードを tier 0 とする
23+
- 各ノードのtierは、その前提スキルの最大tier + 1 で計算
24+
- Y座標: `tier * 120 + 60` (tier間距離120px)
25+
- X座標: 同一tier内のノードを等間隔に配置 (横幅800pxを基準に分散)
26+
27+
## 代替案との比較 (Options)
28+
29+
### 1. 静的レイアウトJSONを作成(カテゴリ別)
30+
31+
- **Good**:
32+
- デザイナーが最適な配置を手動で決定できる
33+
- 視覚的に美しいレイアウトが実現可能
34+
- **Bad**:
35+
- 各カテゴリごとに座標データを手動作成する必要がある(6カテゴリ × 平均20ノード = 120座標)
36+
- カテゴリ追加やノード変更のたびに座標データのメンテナンスが必要
37+
- 実装時間が長い(推定4-6時間)
38+
- **却下理由**: ハッカソン期間の時間制約に合わない
39+
40+
### 2. 全カテゴリ共通の静的座標プリセット
41+
42+
- **Good**:
43+
- 実装が比較的簡単
44+
- 全カテゴリで一貫したレイアウト
45+
- **Bad**:
46+
- カテゴリごとにノード数が異なるため、スペースの無駄や重なりが発生
47+
- スケーラビリティがない(10ノードと30ノードで同じレイアウトは不適切)
48+
- **却下理由**: 柔軟性に欠け、ユーザー体験を損なう可能性
49+
50+
### 3. 力学モデル(Force-Directed Graph)
51+
52+
- **Good**:
53+
- グラフ理論に基づく自然な配置
54+
- ライブラリ(D3.js等)が存在する
55+
- **Bad**:
56+
- ピクセルアート風デザインと相性が悪い(動的なアニメーションが発生)
57+
- 初期表示でノードが動くためUXが低下
58+
- 実装時間が長い
59+
- **却下理由**: プロジェクトのデザイン方針に合わない
60+
61+
## 結果 (Consequences)
62+
63+
### Positive
64+
65+
- ✅ カテゴリ追加時に座標データを手動作成する必要がない
66+
- ✅ バックエンドJSONに座標情報を持たせないため、データ構造がシンプル
67+
- ✅ prerequisites依存関係のみでレイアウトが決定されるため、保守性が高い
68+
- ✅ 実装時間が短い(約1時間)
69+
70+
### Negative
71+
72+
- ❌ ノード配置が機械的で、デザイン性に欠ける可能性
73+
- ❌ 複雑な依存関係グラフの場合、重なりが発生する可能性
74+
- ❌ 同一tier内のノード数が多い場合、横に広がりすぎる(現状は考慮していない)
75+
76+
### 将来の改善案
77+
78+
- デザイナーによるレイアウト最適化が必要になった場合、座標オーバーライド機能を追加可能
79+
- 横幅の自動調整ロジック追加(tier内ノード数に応じて最大幅を制限)
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# 015 初回アクセス時のスキルツリー自動生成
2+
3+
## ステータス
4+
5+
- [x] **決定**
6+
7+
## コンテキスト (課題と背景)
8+
9+
Issue #74でスキルツリーのフロントエンド・バックエンドAPI統合を行う際、ユーザーが初めて特定のカテゴリのスキルツリーにアクセスした場合の挙動を決定する必要があった。
10+
11+
### 背景
12+
- バックエンドの`GET /api/v1/users/{id}/skill-trees?category={cat}`は、データベースに該当データがない場合は空配列`[]`を返す
13+
- スキルツリーの生成には`POST /api/v1/analyze/skill-tree`を呼び出す必要がある
14+
- LLM API呼び出しが含まれるため、生成には最大30秒かかる可能性がある
15+
- ユーザーに生成ボタンを押させる手間を減らしたい
16+
17+
## 決定 (Decision)
18+
19+
**GETで空配列が返った場合、フロントエンド側で自動的にPOSTエンドポイントを呼び出し、スキルツリーを生成する**
20+
21+
### 実装方針
22+
```typescript
23+
export async function fetchSkillTree(
24+
userId: number,
25+
category: string
26+
): Promise<SkillTreeData | null> {
27+
const response = await fetch(
28+
`${API_BASE_URL}/users/${userId}/skill-trees?category=${category}`
29+
);
30+
const data = await response.json();
31+
32+
// 空配列の場合は自動生成
33+
if (data.length === 0) {
34+
return await generateSkillTree(userId, category);
35+
}
36+
return data[0];
37+
}
38+
```
39+
40+
## 代替案との比較 (Options)
41+
42+
### 1. ユーザーに「生成」ボタンをクリックさせる
43+
44+
- **Good**:
45+
- ユーザーが明示的に生成を開始するため、意図しないAPI料金消費が発生しない
46+
- 生成が必要かどうかをユーザーが判断できる
47+
- **Bad**:
48+
- UXが悪い(空の画面→ボタンクリック→待機のステップが発生)
49+
- 初回アクセス時に追加操作が必要になる
50+
- **却下理由**: ハッカソンのデモとしてシームレスなUXを優先したい
51+
52+
### 2. バックエンドで初回アクセス時に自動生成
53+
54+
- **Good**:
55+
- フロントエンドのロジックがシンプル
56+
- バックエンドで生成タイミングを一元管理
57+
- **Bad**:
58+
- GETエンドポイントが副作用(DB書き込み)を持つことになり、RESTful設計に反する
59+
- 初回アクセスの場合、レスポンスが遅くなる(最大30秒)→タイムアウトリスク
60+
- **却下理由**: RESTful設計の原則を守り、GET/POSTの責任分離を維持したい
61+
62+
### 3. データベースシーディング時に全ユーザー・全カテゴリのスキルツリーを事前生成
63+
64+
- **Good**:
65+
- 初回アクセスが常に高速
66+
- フロントエンドの実装が単純
67+
- **Bad**:
68+
- ユーザー登録時に6カテゴリ全てを生成するため、LLM API料金が6倍かかる
69+
- ユーザーが使わないカテゴリも生成してしまう(無駄なコスト)
70+
- **却下理由**: API料金の無駄遣いとスケーラビリティの問題
71+
72+
## 結果 (Consequences)
73+
74+
### Positive
75+
76+
- ✅ ユーザーが手動で「生成」ボタンを押す必要がない
77+
- ✅ シームレスなUX(初回アクセスでもスキルツリーが自動的に表示される)
78+
- ✅ RESTful設計を維持(GET/POSTの責任分離)
79+
- ✅ ユーザーがアクセスしたカテゴリのみ生成されるため、API料金を最小化
80+
81+
### Negative
82+
83+
- ❌ 初回ロードが遅くなる(LLM API呼び出しで最大30秒)
84+
- ❌ ユーザーが意図せずAPI料金を消費する可能性がある(カテゴリ切り替え時に自動生成が走る)
85+
- ❌ ローディング中のユーザー体験がやや低下(待機時間の発生)
86+
87+
### 軽減策
88+
89+
- **10分間のキャッシュ**: バックエンドで生成結果を10分間キャッシュすることで、同じカテゴリへの再アクセスは高速
90+
- **ローディングスピナー**: 待機状態を視覚的に明示し、ユーザーに進行状況を伝える
91+
- **エラーハンドリング**: 生成失敗時は明確なエラーメッセージを表示し、再試行を促す
92+
93+
### 将来の改善案
94+
95+
- バックエンドで生成状況をWebSocket等でリアルタイムに通知(プログレスバー表示)
96+
- ユーザー設定で「自動生成」のオン/オフを切り替え可能にする
97+
- 生成処理をバックグラウンドジョブ化し、完了後に通知(非同期処理)

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,8 +206,10 @@ dist/
206206
downloads/
207207
eggs/
208208
.eggs/
209+
# Python virtual environment lib directories (but not frontend/src/lib)
209210
lib/
210211
lib64/
212+
!frontend/src/lib/
211213
parts/
212214
sdist/
213215
var/

backend/app/api/endpoints/analyze.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
from app.services.skill_tree_service import generate_skill_tree_ai
2929
from app.services.quest_service import generate_handson_quest
3030
from app.crud.user import get_user
31+
from app.dependencies.auth import get_current_user
32+
from app.models.user import User
3133
from app.db.session import get_db
3234

3335
router = APIRouter()
@@ -78,28 +80,30 @@ async def analyze_rank(request: RankAnalysisRequest) -> RankAnalysisResponse:
7880

7981
@router.post("/skill-tree", response_model=SkillTreeResponse)
8082
async def generate_skill_tree(
81-
request: SkillTreeRequest, db: Session = Depends(get_db)
83+
request: SkillTreeRequest,
84+
current_user: User = Depends(get_current_user),
85+
db: Session = Depends(get_db),
8286
) -> SkillTreeResponse:
8387
"""
84-
スキルツリー生成(LLM実装 - Issue #54)
88+
スキルツリー生成(LLM実装 - Issue #54, 認証必須 - Issue #61
8589
8690
Args:
87-
request: スキルツリー生成リクエスト(user_id, category)
91+
request: スキルツリー生成リクエスト(category)
92+
current_user: 認証済みユーザー(Cookieから自動取得)
8893
db: DBセッション
8994
9095
Returns:
9196
SkillTreeResponse: パーソナライズされたスキルツリーデータ
9297
9398
Note:
94-
- user_id から Profile と SkillTree テーブルを参照
99+
- 認証済みユーザーのProfile と SkillTree テーブルを参照
95100
- GitHub APIでリポジトリを分析(習得済みスキル推定)
96101
- LLMでパーソナライズされたロードマップを生成
97-
- キャッシュ機能(7日間): generated_atが新しければDBから返却
102+
- キャッシュ機能(10分): generated_atが新しければDBから返却
98103
99104
Example:
100105
Request:
101106
{
102-
"user_id": 1,
103107
"category": "web"
104108
}
105109
@@ -116,7 +120,7 @@ async def generate_skill_tree(
116120
"""
117121
try:
118122
result = await generate_skill_tree_ai(
119-
user_id=request.user_id, category=request.category, db=db
123+
user_id=current_user.id, category=request.category, db=db
120124
)
121125
return result
122126
except HTTPException:

backend/app/api/endpoints/auth.py

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ def create_access_token(user_id: int) -> str:
6464
"exp": expire,
6565
"iat": datetime.now(timezone.utc),
6666
}
67-
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
67+
return jwt.encode(
68+
payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM
69+
)
6870

6971

7072
def set_auth_cookie(response: Response, token: str) -> None:
@@ -138,10 +140,14 @@ def github_login() -> RedirectResponse:
138140
"""
139141
if not settings.GITHUB_CLIENT_ID:
140142
raise HTTPException(
141-
status_code=503, detail="GitHub OAuth は設定されていません (GITHUB_CLIENT_ID)"
143+
status_code=503,
144+
detail="GitHub OAuth は設定されていません (GITHUB_CLIENT_ID)",
142145
)
143146
# JWT 署名用設定が未整備の場合は HMAC を安全に生成できないため早期に失敗させる
144-
if not getattr(settings, "JWT_SECRET_KEY", None) or not settings.JWT_SECRET_KEY.strip():
147+
if (
148+
not getattr(settings, "JWT_SECRET_KEY", None)
149+
or not settings.JWT_SECRET_KEY.strip()
150+
):
145151
raise HTTPException(
146152
status_code=503, detail="JWT 設定が正しくありません (JWT_SECRET_KEY)"
147153
)
@@ -194,10 +200,14 @@ async def github_callback(
194200
# これにより「攻撃者が取得した state を被害者に踏ませる」Login CSRF を防ぐ。
195201
state_cookie = request.cookies.get("oauth_state")
196202
if not state_cookie or not secrets.compare_digest(state_cookie, state):
197-
raise HTTPException(status_code=400, detail="Invalid or expired state parameter")
203+
raise HTTPException(
204+
status_code=400, detail="Invalid or expired state parameter"
205+
)
198206
# さらに HMAC 署名を検証してサーバー発行の state かを確認する
199207
if not _verify_state(state):
200-
raise HTTPException(status_code=400, detail="Invalid or expired state parameter")
208+
raise HTTPException(
209+
status_code=400, detail="Invalid or expired state parameter"
210+
)
201211

202212
# --- 2. GitHub アクセストークン取得 ---
203213
# 環境変数未設定時は外部 API を呼ばずに早期に 503 を返す(運用上の切り分け用)
@@ -234,7 +244,9 @@ async def github_callback(
234244
access_token: str | None = token_data.get("access_token")
235245
if not access_token:
236246
# GitHub は 200 でエラーを返すことがある
237-
error_desc = token_data.get("error_description", token_data.get("error", "No access token"))
247+
error_desc = token_data.get(
248+
"error_description", token_data.get("error", "No access token")
249+
)
238250
raise HTTPException(status_code=400, detail=error_desc)
239251

240252
# --- 3. GitHub ユーザー情報取得 ---
@@ -258,7 +270,9 @@ async def github_callback(
258270
github_user_id_raw = github_user.get("id")
259271
github_login_name: str | None = github_user.get("login")
260272
if not github_user_id_raw or not github_login_name:
261-
raise HTTPException(status_code=502, detail="GitHub ユーザー情報の取得に失敗しました")
273+
raise HTTPException(
274+
status_code=502, detail="GitHub ユーザー情報の取得に失敗しました"
275+
)
262276
github_user_id = str(github_user_id_raw)
263277

264278
# --- 4. 既存ユーザー照合 or 新規作成 ---
@@ -283,7 +297,9 @@ async def github_callback(
283297
# これにより「User だけが永続化されるゾンビレコード」を防ぐ。
284298
# create_user / create_oauth_account の flush も含めて全体を囲う(C-1修正)
285299
try:
286-
db_user = crud_user.create_user(db, UserCreate(username=username), commit=False)
300+
db_user = crud_user.create_user(
301+
db, UserCreate(username=username), commit=False
302+
)
287303
# OAuthAccount 作成(GitHubトークンは暗号化保存: ADR 005)
288304
crud_oauth.create_oauth_account(
289305
db,
@@ -298,16 +314,22 @@ async def github_callback(
298314
db.commit()
299315
except Exception as e:
300316
db.rollback()
301-
raise HTTPException(status_code=500, detail="ユーザー登録に失敗しました") from e
317+
raise HTTPException(
318+
status_code=500, detail="ユーザー登録に失敗しました"
319+
) from e
302320
db.refresh(db_user) # commit 後に refresh(rollback 対象外)
303321

304322
if db_user is None:
305-
raise HTTPException(status_code=500, detail="User lookup failed after OAuth flow")
323+
raise HTTPException(
324+
status_code=500, detail="User lookup failed after OAuth flow"
325+
)
306326

307327
# --- 5. JWT を httpOnly Cookie にセットしてフロントへリダイレクト ---
308328
# JWT を URL パラメータに含めない(ブラウザ履歴・Referer への漏洩防止)
309329
jwt_token = create_access_token(db_user.id)
310-
redirect = RedirectResponse(url=settings.FRONTEND_URL, status_code=302)
330+
# GitHub OAuth 成功後はダッシュボードへリダイレクト(Issue #74)
331+
dashboard_url = f"{settings.FRONTEND_URL}/dashboard"
332+
redirect = RedirectResponse(url=dashboard_url, status_code=302)
311333
set_auth_cookie(redirect, jwt_token)
312334
# oauth_state Cookie を使用済みにする(ワンタイム化)
313335
is_https = urlparse(settings.FRONTEND_URL).scheme == "https"
@@ -429,4 +451,3 @@ def login_by_username(
429451
)
430452
set_auth_cookie(response, token)
431453
return {"message": "ログインしました", "user_id": db_user.id}
432-

backend/app/core/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ class Settings(BaseSettings):
2727
# 生成: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
2828
ENCRYPTION_KEY: str = ""
2929

30+
# スキルツリー生成設定
31+
SKIP_LLM_FOR_SKILL_TREE: bool = (
32+
True # True: ベースラインJSONを直接返す(開発用), False: LLMを使用
33+
)
34+
3035
# ランク計算(product-spec 4.1 準拠)
3136
# ランクn に到達するために必要な累積経験値(仕様確定後に調整)
3237
RANK_THRESHOLDS: list[int] = [

backend/app/core/prompts.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@
8989
{baseline_json}
9090
9191
## 生成要件
92-
1. **未習得スキルの特定**: ベースラインから、ユーザーが次に学ぶべきスキル5-10個を選定
92+
1. **ベースラインをベースに**: ベースラインスキルツリーの全ノードを含めつつ、ユーザーのレベルに応じてカスタマイズ
9393
2. **completed判定(最重要)**:
9494
- 「習得済みスキル」リストに含まれるスキルIDは必ず `completed: true` に設定
9595
- 含まれないスキルは `completed: false` に設定
@@ -100,6 +100,7 @@
100100
- rank 3-5(中級者): 実践的スキル中心、標準的な学習時間
101101
- rank 6-9(上級者): 先端技術・アーキテクチャスキル、短めの学習時間
102102
5. **優先順位付け**: 次に取り組むべきスキル(next_recommended)を3つ提示
103+
6. **ベースラインの全ノードを含める**: ノード数を減らさず、ベースラインの20個程度のノードをすべて含めてください
103104
104105
## 出力形式(JSON)
105106
{{

0 commit comments

Comments
 (0)