feat: #88 スキルツリーAI生成の最適化と三角形レイアウト改善#95
Conversation
ベースライン固定20ノードからパーソナライズ可能な20-30ノードに変更: - ベースライン重要ノード: 10-15個 - パーソナライズノード: 10-15個(ユーザーレベルに応じて調整) - 初心者: 基礎手厚く(20-25ノード) - 上級者: 応用・発展重視(25-30ノード)
Part 1の実装内容を検証: - 旧要件の削除確認 - 新要件(20-30ノード、パーソナライズロジック)の追加確認 - 全チェック項目PASS
## 実装内容 ### ストリーミング生成(プログレッシブ表示) - LLMストリーミング関数追加(LangChain astream使用) - SSEエンドポイント実装: GET /api/v1/analyze/skill-tree/stream - JSON Lines形式プロンプト(1行1ノード出力) - フロントエンド: トポロジカルソートでノードを依存関係順に表示 - バッファリング機能: 全ノード受信後にソートしてから段階的表示 ### パフォーマンス最適化(Few-shot prompting) - ベースライン簡略化: 全ノード送信 → 参考2例のみ - プロンプト簡潔化: 要件5つ → 3つ(基礎必須のみ) - モデル変更: gpt-4o-mini → gpt-3.5-turbo-0125(2-3倍高速化) - LLMタイムアウト延長: 30秒 → 60秒 - 生成ノード数削減: 20-30個 → 15-20個 - トークン削減率: 82.5%(1,614 → 282 tokens) ### 技術詳細 **バックエンド**: - `app/core/llm.py`: stream_llm() 追加 - `app/core/prompts_streaming.py`: JSON Lines形式テンプレート(依存関係順出力指示) - `app/api/endpoints/analyze.py`: SSEエンドポイント実装 - `app/core/prompts.py`: Few-shot化(2例のみ) - `app/core/config.py`: gpt-3.5-turbo-0125設定 - `app/services/skill_tree_service.py`: ベースライン簡略化関数追加 **フロントエンド**: - `lib/api/skillTree.ts`: - streamSkillTree(): 基本ストリーミングAPI - streamSkillTreeBuffered(): バッファリング+ソート版 - sortNodesByDependencies(): トポロジカルソート実装 **テスト**: - `backend/scripts/test_streaming.py`: SSE動作確認 - `backend/scripts/test_generation_speed.py`: 生成速度計測 - `frontend/test-buffered-streaming.ts`: ソート機能検証 **ドキュメント**: - `.github/STREAMING_IMPLEMENTATION.md`: 実装ガイド・使用例 ## パフォーマンス改善 | 項目 | 従来 | 最適化後 | 改善率 | |------|------|----------|--------| | トークン数 | 1,614 | 282 | -82.5% | | 生成時間 | 20-30秒 | 10-15秒 | -50% | | 初期表示 | 20-30秒待機 | 2-3秒 | 体感速度大幅改善 | | UX | 待機のみ | プログレッシブ表示 | ⭐⭐⭐ | ## セキュリティ考慮事項 - SSEエンドポイントは認証必須(Depends(get_current_user)) - EventSource使用時に credentials="include" でCookie送信 - JSON Linesパースエラーハンドリング実装 ## 残課題 - フロントエンド実装: DashboardContainerへの統合 - UX改善: ローディングアニメーション・進捗率表示 - エラーハンドリング: フォールバック(従来エンドポイント使用) Closes: Part of #88
- Tier構成を1→3→8→18の4層に変更(段階的に広がる△型) - スキル名を3-5単語に短縮(詳細はdescriptionへ) - Few-shotでない説明例を削除(プロンプト最適化) Closes #88"
5c01543 to
d276584
Compare
There was a problem hiding this comment.
Pull request overview
このPRは、スキルツリーAI生成の最適化を目的としており、トークン消費量の削減(30-40%)と生成速度の向上(25-35%)を実現します。主な変更として、APIフィールド名の短縮(description→desc、estimated_hours→hours)、説明文の軽量化(60→30文字)、Few-shot例の削減(8→3ノード)、tierベースの三角形レイアウトアルゴリズム、そしてServer-Sent Events(SSE)を使用したストリーミング機能の実装が含まれます。
Changes:
- APIフィールド名を短縮してJSON payload サイズを削減
- 三角形△レイアウトアルゴリズムを実装(Tier 0-5、下層ほどノード数増加)
- SSEストリーミングエンドポイントとフロントエンド統合を追加
- プロンプトテンプレートを簡素化してトークン消費を削減
- LLMモデルをgpt-3.5-turbo-0125に変更し、温度設定を最適化
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 18 comments.
Show a summary per file
| File | Description |
|---|---|
| backend/app/services/skill_tree_service.py | Few-shot例抽出用の_simplify_baseline_for_prompt()関数を追加、temperature調整 |
| backend/app/core/prompts.py | プロンプトテンプレートを簡素化し、tier構造を明確化 |
| backend/app/core/prompts_streaming.py | SSE用の新しいストリーミングプロンプトテンプレートを追加 |
| backend/app/core/llm.py | stream_llm()関数追加、デフォルトタイムアウトを60秒に延長 |
| backend/app/core/config.py | デフォルトモデルをgpt-3.5-turbo-0125に変更 |
| backend/app/api/endpoints/analyze.py | /skill-tree/streamエンドポイントを追加してSSE対応 |
| backend/app/services/mock_ai_service.py | モックデータを新しいフィールド名(desc/hours)に更新 |
| frontend/src/lib/api/skillTree.ts | 新しいフィールド名(desc/hours)に更新、ストリーミング関数を追加 |
| frontend/src/features/skill-tree/utils/converter.ts | 三角形レイアウトアルゴリズムを実装(tierベースの動的間隔) |
| frontend/src/features/dashboard/components/DashboardContainer.tsx | ストリーミング統合、プログレスバー、クリーンアップロジックを追加 |
| backend/tests/test_services/test_skill_tree_service.py | テストアサーションを更新 |
| frontend/test-buffered-streaming.ts | トポロジカルソート検証用のテストスクリプトを追加 |
| backend/scripts/* | 各種テスト・ユーティリティスクリプトを追加 |
| console.log("Metadata:", metadata); | ||
| }, | ||
| // 完了コールバック(ここで一度だけ変換) | ||
| () => { | ||
| if (isCompleted || !isMounted) { | ||
| console.log("⚠️ 完了コールバック重複実行を防止"); | ||
| return; // 既に完了済み、またはアンマウント済みなら何もしない | ||
| } | ||
|
|
||
| isCompleted = true; | ||
| if (progressInterval) clearInterval(progressInterval); | ||
|
|
||
| console.log("🔍 完了コールバック開始"); | ||
| console.log(" receivedNodes count:", receivedNodes.length); | ||
| console.log(" receivedNodes sample:", receivedNodes.slice(0, 3)); | ||
|
|
||
| // 全ノード受信完了 → 座標計算して表示 | ||
| const canvasNodes = convertApiNodesToCanvasNodes( | ||
| receivedNodes, | ||
| category, | ||
| ); | ||
|
|
||
| console.log(" ✅ canvasNodes 計算完了:", canvasNodes.length); | ||
| console.log( | ||
| " Tier 0 nodes:", | ||
| canvasNodes.filter((n) => n.tier === 0).length, | ||
| ); | ||
| console.log( | ||
| " Tier distribution:", | ||
| Array.from(new Set(canvasNodes.map((n) => n.tier))) | ||
| .sort((a, b) => a - b) | ||
| .map( | ||
| (tier) => | ||
| `Tier${tier}:${canvasNodes.filter((n) => n.tier === tier).length}`, | ||
| ), | ||
| ); | ||
|
|
||
| setSkillTreeNodes(canvasNodes); | ||
| console.log(" ✅ setSkillTreeNodes 完了"); | ||
| setStreamProgress(100); | ||
|
|
||
| // 0.5秒後にプログレスバーを非表示 | ||
| setTimeout(() => { | ||
| if (!isMounted) return; | ||
| console.log(" ℹ️ setIsStreaming(false) - 0.5秒後"); |
There was a problem hiding this comment.
複数のconsole.logステートメント(125, 130, 137-160, 163, 169行)が本番コードに残されています。これらはデバッグ目的のログと思われますが、本番環境ではブラウザのコンソールを混雑させ、パフォーマンスに影響を与える可能性があります。
開発時のみconsole.logを実行するように条件付きにするか(例:if (process.env.NODE_ENV === 'development'))、または削除することを検討してください。
| **【必須】Tier 0からTier 5までのノード数配分(厳格に遵守):** | ||
| - Tier 0(基礎): 1-2個 ← 最上層、最小 | ||
| - Tier 1(初級): 2-4個 | ||
| - Tier 2(中級): 4-8個 | ||
| - Tier 3(応用): 8-12個 | ||
| - Tier 4(高度): 12-16個 | ||
| - Tier 5(極限): 16-20個 ← 最下層、最大 | ||
|
|
||
| **三角形△構造の形成:** | ||
| 上に行くほど狭く、下に行くほど広い逆三角形を形成。 | ||
| 各Tierで**指定範囲内でできるだけ多くのノード**を生成すること。 | ||
|
|
||
| **依存関係のルール:** | ||
| - 各ノードのprerequisitesは、**必ず一つ前のTierのノード**のみを指定 | ||
| - Tier 0: prerequisites:[] | ||
| - Tier 1: prerequisites:[Tier 0のノード] | ||
| - Tier 2: prerequisites:[Tier 1のノード] | ||
| - Tier 3: prerequisites:[Tier 2のノード] | ||
| - Tier 4: prerequisites:[Tier 3のノード] | ||
| - Tier 5: prerequisites:[Tier 4のノード] | ||
|
|
||
| **重要:** Tierが深くなるほど、ノード数を増やすこと。これにより下に行くほど横に広がる三角形△を形成する。 | ||
|
|
||
| **【絶対厳守】 9, 10...などの階層は絶対に生成しないでください。** | ||
|
|
||
| ## スキル名の命名規則(必須): | ||
| - **キーワード中心、3-5単語以内** | ||
| - **名詞・技術用語のみ、動詞は不要** | ||
|
|
||
| ## 説明(desc)の要件: | ||
| - **スキル名で伝えきれない詳細情報を簡潔に記載** | ||
| - 30文字以内の簡潔な説明 | ||
| - 何ができるようになるかのポイントのみ | ||
|
|
||
| ## 生成手順(厳守): | ||
| 1. Tier 0: 1-2個のノードを出力(少なく) | ||
| 2. Tier 1: 2-4個のノードを出力 | ||
| 3. Tier 2: 4-8個のノードを出力(範囲内でできるだけ多く) | ||
| 4. Tier 3: 8-12個のノードを出力(範囲内でできるだけ多く) | ||
| 5. Tier 4: 12-16個のノードを出力(範囲内でできるだけ多く) | ||
| 6. Tier 5: 16-20個のノードを出力(最も多く) | ||
|
|
||
| **CRITICAL: 下層(Tier 3-5)ほど、指定範囲の上限に近い数を生成** | ||
| 各ノードのprerequisites: [一つ前のTierのノード] | ||
|
|
||
| ## 出力ルール: | ||
| 1. **合計50-60ノード程度**(Tier 0からTier 5まで、下層ほど多く) |
There was a problem hiding this comment.
プロンプトがTier 0-5で合計50-60ノードを要求していますが、これは数学的に矛盾しています。各Tierの最大値を合計すると:1-2 + 2-4 + 4-8 + 8-12 + 12-16 + 16-20 = 43-62ノードとなり、最小値(43)が目標範囲(50-60)を下回ります。
より一貫性のある指示にするために、Tierごとのノード数範囲を調整するか、合計目標範囲を調整することを検討してください。例えば、各Tierで範囲の上限に近い数を生成するように強調するか、合計目標を「45-62ノード」に変更します。
| { | ||
| "id": node.get("id"), | ||
| "name": node.get("name"), | ||
| "desc": node.get("desc", "")[:30] + "...", |
There was a problem hiding this comment.
ベースラインJSONファイルは依然として description フィールドを使用していますが、コードは desc フィールドを期待しています。node.get("desc", "") は空文字列を返すため、Few-shot exampleが意図したとおりに機能しません。
node.get("description", "") を使用するか、ベースラインJSONファイルを更新してフィールド名を desc に変更する必要があります。同じ問題が294行、306行、320行の hours フィールドにも存在します(ベースラインは estimated_hours を使用)。
| "name": node.get("name"), | ||
| "desc": node.get("desc", "")[:30] + "...", | ||
| "prerequisites": [], | ||
| "hours": node.get("hours", 0), |
There was a problem hiding this comment.
同じフィールド名の不一致がここにも存在します。node.get("hours", 0) の代わりに node.get("estimated_hours", 0) を使用する必要があります。
| { | ||
| "id": node.get("id"), | ||
| "name": node.get("name"), | ||
| "desc": node.get("desc", "")[:30] + "...", |
There was a problem hiding this comment.
同じフィールド名の不一致がここにも存在します。node.get("desc", "") の代わりに node.get("description", "") を使用する必要があります。
| model: str | None = None, | ||
| temperature: float = 0.7, | ||
| timeout: int = 30, | ||
| timeout: int = 60, |
There was a problem hiding this comment.
タイムアウトを30秒から60秒に増やすことは、50-60ノードの大規模なスキルツリー生成には妥当ですが、通常のAPI呼び出し(小さなプロンプト)にも適用されます。
特定の関数(スキルツリー生成など)でのみ長いタイムアウトが必要な場合は、呼び出し側で get_llm(timeout=60) を指定することを検討してください。デフォルト値を変更すると、すべてのLLM呼び出しに影響します。
| timeout: int = 60, | |
| timeout: int = 30, |
backend/app/core/prompts.py
Outdated
|
|
||
| **【絶対厳守】 9, 10...などの階層は絶対に生成しないでください。** | ||
|
|
||
| ## スキル名の命名規则(必須): |
There was a problem hiding this comment.
「規则」は中国語の簡体字です。日本語では「規則」を使用する必要があります。
| ## スキル名の命名規则(必須): | |
| ## スキル名の命名規則(必須): |
| except Exception as e: | ||
| # エラー通知 | ||
| error_msg = json.dumps( | ||
| {"type": "error", "message": str(e)}, ensure_ascii=False | ||
| ) | ||
| yield f"data: {error_msg}\n\n" |
There was a problem hiding this comment.
エラーが発生した場合でも、nodesリストに保存されているノードがDBにキャッシュされません。try-except ブロックは generate_sse() 関数内にあり、例外をキャッチしてエラーメッセージを送信しますが、DBへの保存(289-313行)は実行されません。
部分的なデータでもユーザーに価値がある場合は、エラー時にもDBに保存することを検討してください。そうでなければ、現在の動作が意図通りであることをコメントで明確にしてください。
| "name": node.get("name"), | ||
| "desc": node.get("desc", "")[:30] + "...", | ||
| "prerequisites": node.get("prerequisites", [])[:2], # 最大2個まで | ||
| "hours": node.get("hours", 0), |
There was a problem hiding this comment.
同じフィールド名の不一致がここにも存在します。node.get("hours", 0) の代わりに node.get("estimated_hours", 0) を使用する必要があります。
| console.log("🟦 convertApiNodesToCanvasNodes 開始"); | ||
| console.log(" Input nodes:", apiNodes.length); |
There was a problem hiding this comment.
本番コードに残されたconsole.logステートメント(17-18行)があります。これらは開発時のデバッグに役立ちますが、本番環境ではパフォーマンスに影響を与え、コンソールを混雑させる可能性があります。
開発時のみconsole.logを実行するように条件付きにするか(例:if (process.env.NODE_ENV === 'development'))、または削除することを検討してください。
| console.log("🟦 convertApiNodesToCanvasNodes 開始"); | |
| console.log(" Input nodes:", apiNodes.length); | |
| if (process.env.NODE_ENV === "development") { | |
| console.log("🟦 convertApiNodesToCanvasNodes 開始"); | |
| console.log(" Input nodes:", apiNodes.length); | |
| } |
Issue: #88
📝 変更概要
スキルツリー生成時間を短縮し、視覚的に下に行くほど横幅が広がる三角形△レイアウトを実現しました。
🎯 変更内容
1. フィールド名の短縮化(トークン削減)
変更前:
description(11文字)estimated_hours(15文字)変更後:
desc(4文字)hours(5文字)影響範囲:
prompts_streaming.py,prompts.py,skill_tree_service.py,mock_ai_service.pyskillTree.ts,converter.ts2. 説明文の軽量化
変更前: 最低60文字以上の詳細な説明
変更後: 30文字以内の簡潔な説明
理由:
3. Few-shotの削減
変更前: 8ノードの例示(約2000トークン)
変更後: 3ノードの例示(フォーマット参考のみ)
4. 三角形レイアウトの最適化
変更前: 固定間隔180px
変更後: tierごとに段階的に拡大
5. プロンプトの強化
下層(Tier 3-5)ほど指定範囲の上限に近い数を生成するよう明示
🔧 技術的な意思決定とトレードオフ
採用したアプローチ
1. フィールド名短縮
descよりdescriptionの方が明確)2. 説明文の軽量化(60→30文字)
3. tierベースの動的間隔
却下した代替案
1. ノード数固定 + Few-shot大量提供
2. 完全な遅延評価(ノードクリック時に詳細生成)
🧪 テスト戦略と範囲
テスト済み
npm run lint)- パスnpm run build)- パスpoetry run ruff check --fix .)- 15エラー自動修正poetry run pytest)- 225テスト実行中テストしていないこと
📊 期待される効果
🔗 関連ファイル
バックエンド(Python):
backend/app/core/prompts_streaming.pybackend/app/core/prompts.pybackend/app/services/skill_tree_service.pybackend/app/services/mock_ai_service.pyフロントエンド(TypeScript):
frontend/src/lib/api/skillTree.tsfrontend/src/features/skill-tree/utils/converter.tsfrontend/src/features/dashboard/components/DashboardContainer.tsxfrontend/src/features/skill-tree/components/SkillNodePanel.tsx✅ チェックリスト
Closes #88