Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions .github/decisions/014-skill-tree-coordinate-auto-layout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# 014 スキルツリーの座標自動レイアウト生成

## ステータス

- [x] **決定**

## コンテキスト (課題と背景)

Issue #74でスキルツリーのフロントエンド・バックエンドAPI統合を行う際、バックエンドから返されるノードデータをCanvas上にどう配置するかを決定する必要があった。

### 背景
- バックエンドのJSONには`prerequisites`(前提スキル)の依存関係のみが存在し、X/Y座標情報は存在しない
- 6カテゴリ(web/ai/security/infrastructure/game/design)それぞれに対応する必要がある
- 将来的にカテゴリやスキルノード数が増減する可能性がある
- ハッカソン期間のため、実装時間を最小化したい

## 決定 (Decision)

**APIデータから`prerequisites`の深さに基づいてtier(階層)を計算し、X/Y座標を自動生成する方式を採用**

### 実装方針
- 前提スキルを持たないノードを tier 0 とする
- 各ノードのtierは、その前提スキルの最大tier + 1 で計算
- Y座標: `tier * 120 + 60` (tier間距離120px)
- X座標: 同一tier内のノードを等間隔に配置 (横幅800pxを基準に分散)

## 代替案との比較 (Options)

### 1. 静的レイアウトJSONを作成(カテゴリ別)

- **Good**:
- デザイナーが最適な配置を手動で決定できる
- 視覚的に美しいレイアウトが実現可能
- **Bad**:
- 各カテゴリごとに座標データを手動作成する必要がある(6カテゴリ × 平均20ノード = 120座標)
- カテゴリ追加やノード変更のたびに座標データのメンテナンスが必要
- 実装時間が長い(推定4-6時間)
- **却下理由**: ハッカソン期間の時間制約に合わない

### 2. 全カテゴリ共通の静的座標プリセット

- **Good**:
- 実装が比較的簡単
- 全カテゴリで一貫したレイアウト
- **Bad**:
- カテゴリごとにノード数が異なるため、スペースの無駄や重なりが発生
- スケーラビリティがない(10ノードと30ノードで同じレイアウトは不適切)
- **却下理由**: 柔軟性に欠け、ユーザー体験を損なう可能性

### 3. 力学モデル(Force-Directed Graph)

- **Good**:
- グラフ理論に基づく自然な配置
- ライブラリ(D3.js等)が存在する
- **Bad**:
- ピクセルアート風デザインと相性が悪い(動的なアニメーションが発生)
- 初期表示でノードが動くためUXが低下
- 実装時間が長い
- **却下理由**: プロジェクトのデザイン方針に合わない

## 結果 (Consequences)

### Positive

- ✅ カテゴリ追加時に座標データを手動作成する必要がない
- ✅ バックエンドJSONに座標情報を持たせないため、データ構造がシンプル
- ✅ prerequisites依存関係のみでレイアウトが決定されるため、保守性が高い
- ✅ 実装時間が短い(約1時間)

### Negative

- ❌ ノード配置が機械的で、デザイン性に欠ける可能性
- ❌ 複雑な依存関係グラフの場合、重なりが発生する可能性
- ❌ 同一tier内のノード数が多い場合、横に広がりすぎる(現状は考慮していない)

### 将来の改善案

- デザイナーによるレイアウト最適化が必要になった場合、座標オーバーライド機能を追加可能
- 横幅の自動調整ロジック追加(tier内ノード数に応じて最大幅を制限)
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# 015 初回アクセス時のスキルツリー自動生成

## ステータス

- [x] **決定**

## コンテキスト (課題と背景)

Issue #74でスキルツリーのフロントエンド・バックエンドAPI統合を行う際、ユーザーが初めて特定のカテゴリのスキルツリーにアクセスした場合の挙動を決定する必要があった。

### 背景
- バックエンドの`GET /api/v1/users/{id}/skill-trees?category={cat}`は、データベースに該当データがない場合は空配列`[]`を返す
- スキルツリーの生成には`POST /api/v1/analyze/skill-tree`を呼び出す必要がある
- LLM API呼び出しが含まれるため、生成には最大30秒かかる可能性がある
- ユーザーに生成ボタンを押させる手間を減らしたい

## 決定 (Decision)

**GETで空配列が返った場合、フロントエンド側で自動的にPOSTエンドポイントを呼び出し、スキルツリーを生成する**

### 実装方針
```typescript
export async function fetchSkillTree(
userId: number,
category: string
): Promise<SkillTreeData | null> {
const response = await fetch(
`${API_BASE_URL}/users/${userId}/skill-trees?category=${category}`
);
const data = await response.json();

// 空配列の場合は自動生成
if (data.length === 0) {
return await generateSkillTree(userId, category);
}
return data[0];
}
```

## 代替案との比較 (Options)

### 1. ユーザーに「生成」ボタンをクリックさせる

- **Good**:
- ユーザーが明示的に生成を開始するため、意図しないAPI料金消費が発生しない
- 生成が必要かどうかをユーザーが判断できる
- **Bad**:
- UXが悪い(空の画面→ボタンクリック→待機のステップが発生)
- 初回アクセス時に追加操作が必要になる
- **却下理由**: ハッカソンのデモとしてシームレスなUXを優先したい

### 2. バックエンドで初回アクセス時に自動生成

- **Good**:
- フロントエンドのロジックがシンプル
- バックエンドで生成タイミングを一元管理
- **Bad**:
- GETエンドポイントが副作用(DB書き込み)を持つことになり、RESTful設計に反する
- 初回アクセスの場合、レスポンスが遅くなる(最大30秒)→タイムアウトリスク
- **却下理由**: RESTful設計の原則を守り、GET/POSTの責任分離を維持したい

### 3. データベースシーディング時に全ユーザー・全カテゴリのスキルツリーを事前生成

- **Good**:
- 初回アクセスが常に高速
- フロントエンドの実装が単純
- **Bad**:
- ユーザー登録時に6カテゴリ全てを生成するため、LLM API料金が6倍かかる
- ユーザーが使わないカテゴリも生成してしまう(無駄なコスト)
- **却下理由**: API料金の無駄遣いとスケーラビリティの問題

## 結果 (Consequences)

### Positive

- ✅ ユーザーが手動で「生成」ボタンを押す必要がない
- ✅ シームレスなUX(初回アクセスでもスキルツリーが自動的に表示される)
- ✅ RESTful設計を維持(GET/POSTの責任分離)
- ✅ ユーザーがアクセスしたカテゴリのみ生成されるため、API料金を最小化

### Negative

- ❌ 初回ロードが遅くなる(LLM API呼び出しで最大30秒)
- ❌ ユーザーが意図せずAPI料金を消費する可能性がある(カテゴリ切り替え時に自動生成が走る)
- ❌ ローディング中のユーザー体験がやや低下(待機時間の発生)

### 軽減策

- **10分間のキャッシュ**: バックエンドで生成結果を10分間キャッシュすることで、同じカテゴリへの再アクセスは高速
- **ローディングスピナー**: 待機状態を視覚的に明示し、ユーザーに進行状況を伝える
- **エラーハンドリング**: 生成失敗時は明確なエラーメッセージを表示し、再試行を促す

### 将来の改善案

- バックエンドで生成状況をWebSocket等でリアルタイムに通知(プログレスバー表示)
- ユーザー設定で「自動生成」のオン/オフを切り替え可能にする
- 生成処理をバックグラウンドジョブ化し、完了後に通知(非同期処理)
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,10 @@ dist/
downloads/
eggs/
.eggs/
# Python virtual environment lib directories (but not frontend/src/lib)
lib/
lib64/
!frontend/src/lib/
parts/
sdist/
var/
Expand Down
5 changes: 5 additions & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ class Settings(BaseSettings):
# 生成: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
ENCRYPTION_KEY: str = ""

# スキルツリー生成設定
SKIP_LLM_FOR_SKILL_TREE: bool = (
True # True: ベースラインJSONを直接返す(開発用), False: LLMを使用
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SKIP_LLM_FOR_SKILL_TREE のデフォルトが True になっているため、環境変数未設定の環境(本番/CI含む)で常に LLM をスキップしてベースライン固定になります。開発用フラグならデフォルトは False にして .env(または dev 用設定)でのみ True にするのが安全です。

Suggested change
True # True: ベースラインJSONを直接返す(開発用), False: LLMを使用
False # True: ベースラインJSONを直接返す(開発用), False: LLMを使用

Copilot uses AI. Check for mistakes.
)

# ランク計算(product-spec 4.1 準拠)
# ランクn に到達するために必要な累積経験値(仕様確定後に調整)
RANK_THRESHOLDS: list[int] = [
Expand Down
3 changes: 2 additions & 1 deletion backend/app/core/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@
{baseline_json}

## 生成要件
1. **未習得スキルの特定**: ベースラインから、ユーザーが次に学ぶべきスキル5-10個を選定
1. **ベースラインをベースに**: ベースラインスキルツリーの全ノードを含めつつ、ユーザーのレベルに応じてカスタマイズ
2. **completed判定(最重要)**:
- 「習得済みスキル」リストに含まれるスキルIDは必ず `completed: true` に設定
- 含まれないスキルは `completed: false` に設定
Expand All @@ -100,6 +100,7 @@
- rank 3-5(中級者): 実践的スキル中心、標準的な学習時間
- rank 6-9(上級者): 先端技術・アーキテクチャスキル、短めの学習時間
5. **優先順位付け**: 次に取り組むべきスキル(next_recommended)を3つ提示
6. **ベースラインの全ノードを含める**: ノード数を減らさず、ベースラインの20個程度のノードをすべて含めてください

## 出力形式(JSON)
{{
Expand Down
17 changes: 14 additions & 3 deletions backend/app/services/skill_tree_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,15 @@ async def generate_skill_tree_ai(
# Step 3: ベースラインJSON読み込み
baseline_data = _load_baseline_json(category)

# 開発モード: LLMをスキップしてベースラインJSONを直接返す
from app.core.config import settings

if settings.SKIP_LLM_FOR_SKILL_TREE:
logger.info(
f"Skipping LLM for development mode, using baseline JSON for {category.value}"
)
return _fallback_to_baseline(category, baseline_data, github_analysis)

# Step 4: LLMプロンプト生成
prompt = _build_skill_tree_prompt(
profile=profile,
Expand Down Expand Up @@ -298,7 +307,9 @@ def _build_skill_tree_prompt(


def _fallback_to_baseline(
category: SkillCategory, baseline_data: dict[str, Any], github_analysis: dict[str, Any]
category: SkillCategory,
baseline_data: dict[str, Any],
github_analysis: dict[str, Any],
) -> SkillTreeResponse:
"""
LLM呼び出し失敗時のフォールバック: ベースラインJSONを返却(GitHub分析結果は適用)
Expand All @@ -314,7 +325,7 @@ def _fallback_to_baseline(
logger.warning(
f"Falling back to baseline JSON for category={category.value} due to LLM failure"
)

# GitHub分析結果でcompletedフラグを更新
completion_signals = github_analysis.get("completion_signals", {})
if completion_signals:
Expand All @@ -340,7 +351,7 @@ def _fallback_to_baseline(
baseline_data["metadata"] = {}
baseline_data["metadata"]["completed_nodes"] = completed_nodes
baseline_data["metadata"]["progress_percentage"] = round(progress_percentage, 1)

return SkillTreeResponse(
category=category.value,
tree_data=baseline_data,
Expand Down
2 changes: 2 additions & 0 deletions backend/scripts/seed_test_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
# プロジェクトルートをパスに追加
sys.path.insert(0, str(Path(__file__).parent.parent))

# 全モデルを登録するため、base をインポート(重要!)
from app.db import base # noqa: F401
from app.db.session import SessionLocal
from app.models.user import User
from app.models.profile import Profile
Expand Down
6 changes: 6 additions & 0 deletions backend/tests/test_services/test_skill_tree_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,9 @@ async def test_generate_skill_tree_ai_with_github_data(self, db: Session):
"app.services.skill_tree_service.invoke_llm",
new_callable=AsyncMock,
return_value=json.dumps(llm_response),
), patch(
"app.core.config.settings.SKIP_LLM_FOR_SKILL_TREE",
False,
):
# Act
result = await generate_skill_tree_ai(user.id, category, db)
Expand Down Expand Up @@ -284,6 +287,9 @@ async def test_generate_skill_tree_ai_github_overrides_llm(self, db: Session):
"app.services.skill_tree_service.invoke_llm",
new_callable=AsyncMock,
return_value=json.dumps(llm_response),
), patch(
"app.core.config.settings.SKIP_LLM_FOR_SKILL_TREE",
False,
):
# Act
result = await generate_skill_tree_ai(user.id, category, db)
Expand Down
45 changes: 45 additions & 0 deletions frontend/src/features/dashboard/components/CategorySelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* CategorySelector Component
* スキルツリーのカテゴリ選択UI
* Dashboard と /skills ページで共用可能な設計
*/

import React from "react";

export interface CategorySelectorProps {
currentCategory: string;
onCategoryChange: (category: string) => void;
}

const CATEGORIES = [
{ id: "web", name: "Web開発", icon: "🌐" },
{ id: "ai", name: "AI/ML", icon: "🤖" },
{ id: "security", name: "セキュリティ", icon: "🔒" },
{ id: "infrastructure", name: "インフラ", icon: "☁️" },
{ id: "game", name: "ゲーム", icon: "🎮" },
{ id: "design", name: "デザイン", icon: "🎨" },
];
Comment on lines +14 to +21
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

カテゴリIDに infrastructure / game を含めていますが、現在のスキルツリー表示側のカテゴリキー(色・ラベル等)は infra など別名になっているため、選択カテゴリによっては色/ラベル参照が一致せず表示が崩れます。フロント側カテゴリ定義をバックエンドと同一の6種に統一するか、選択値を内部キーに変換する処理を追加してください。

Copilot uses AI. Check for mistakes.

export const CategorySelector: React.FC<CategorySelectorProps> = ({
currentCategory,
onCategoryChange,
}) => {
return (
<div className="mb-4">
<label className="block text-sm font-medium text-[#2C5F2D] mb-2">
カテゴリを選択
</label>
<select
Comment on lines +29 to +32
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

labelselect が関連付いていないため、スクリーンリーダー利用時にフォーム要素の説明が紐づきません。selectid を付け、label 側に htmlFor を設定してください。

Suggested change
<label className="block text-sm font-medium text-[#2C5F2D] mb-2">
カテゴリを選択
</label>
<select
<label
htmlFor="category-selector"
className="block text-sm font-medium text-[#2C5F2D] mb-2"
>
カテゴリを選択
</label>
<select
id="category-selector"

Copilot uses AI. Check for mistakes.
value={currentCategory}
onChange={(e) => onCategoryChange(e.target.value)}
className="bg-white text-[#2C5F2D] border-2 border-[#2C5F2D] rounded-lg px-4 py-2 w-full max-w-xs hover:bg-gray-50 transition-colors cursor-pointer"
>
{CATEGORIES.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.icon} {cat.name}
</option>
))}
</select>
</div>
);
};
Loading
Loading