Skip to content

Commit 5f4c75e

Browse files
authored
Merge pull request #106 from kc3hack/feature/issue-98-ai-quest-result-ui
feat: #98 AI生成演習の結果表示画面を実装
2 parents e178ed5 + af5f6c7 commit 5f4c75e

File tree

14 files changed

+712
-77
lines changed

14 files changed

+712
-77
lines changed

backend/app/dependencies/auth.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,14 @@ def _decode_token(token: str) -> int:
6161
detail="トークンの有効期限が切れています",
6262
headers={"WWW-Authenticate": "Bearer"},
6363
)
64+
except jwt.InvalidSignatureError:
65+
# 署名検証失敗(改ざん検出)
66+
raise credentials_exception
67+
except jwt.DecodeError:
68+
# デコード失敗(不正なフォーマット)
69+
raise credentials_exception
6470
except jwt.PyJWTError:
71+
# その他のJWTエラー
6572
raise credentials_exception
6673

6774

@@ -101,4 +108,3 @@ def get_current_user(
101108
headers={"WWW-Authenticate": "Bearer"},
102109
)
103110
return db_user
104-

backend/app/schemas/quest.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,9 @@ class QuestStep(BaseModel):
9292
"""演習ステップ"""
9393

9494
step_number: int = Field(..., ge=1, description="ステップ番号")
95-
title: str = Field(..., min_length=1, max_length=200, description="ステップタイトル")
95+
title: str = Field(
96+
..., min_length=1, max_length=200, description="ステップタイトル"
97+
)
9698
description: str = Field(..., description="手順の詳細説明")
9799
code_example: str = Field(default="", description="コード例")
98100
checkpoints: list[str] = Field(default_factory=list, description="確認ポイント")
@@ -114,8 +116,12 @@ class QuestGenerationRequest(BaseModel):
114116
document_content: str = Field(
115117
..., min_length=10, max_length=10000, description="学習対象ドキュメント"
116118
)
117-
user_rank: int = Field(..., ge=0, le=9, description="ユーザーランク")
118-
user_skills: str = Field(default="", max_length=500, description="得意分野(オプション)")
119+
user_rank: int = Field(
120+
default=0, ge=0, le=9, description="ユーザーランク(省略時は0=初心者)"
121+
)
122+
user_skills: str = Field(
123+
default="", max_length=500, description="得意分野(オプション)"
124+
)
119125

120126

121127
class QuestGenerationResponse(BaseModel):
@@ -148,4 +154,3 @@ class QuestGenerationResponse(BaseModel):
148154
learning_objectives: list[str] = Field(..., description="学習目標")
149155
steps: list[QuestStep] = Field(..., min_length=1, description="演習ステップ")
150156
resources: list[str] = Field(default_factory=list, description="参考リソース")
151-

backend/app/services/quest_service.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,15 @@
1111

1212
async def generate_handson_quest(
1313
document_content: str,
14-
user_rank: int,
14+
user_rank: int = 0,
1515
user_skills: str = "",
1616
) -> dict:
1717
"""
1818
LLMを使用してハンズオン演習を生成
1919
2020
Args:
2121
document_content: 学習対象のドキュメント
22-
user_rank: ユーザーのランク(0-9)
22+
user_rank: ユーザーのランク(0-9、デフォルト=0
2323
user_skills: ユーザーの得意分野(オプション)
2424
2525
Returns:
@@ -46,17 +46,30 @@ async def generate_handson_quest(
4646
# LLMに非同期で呼び出し (temperature=0.7で創造性を持たせる)
4747
response = await invoke_llm(prompt=prompt, temperature=0.7)
4848

49+
# Markdownコードブロックを除去(LLMが```json ... ```で囲む場合がある)
50+
cleaned_response = response.strip()
51+
if cleaned_response.startswith("```json"):
52+
cleaned_response = cleaned_response[7:] # "```json" を削除
53+
elif cleaned_response.startswith("```"):
54+
cleaned_response = cleaned_response[3:] # "```" を削除
55+
if cleaned_response.endswith("```"):
56+
cleaned_response = cleaned_response[:-3] # 末尾の "```" を削除
57+
cleaned_response = cleaned_response.strip()
58+
4959
# JSONパース(エラーハンドリング付き)
5060
try:
51-
result = json.loads(response)
61+
result = json.loads(cleaned_response)
5262
# 必須フィールドの存在確認
5363
required_fields = ["title", "difficulty", "steps"]
5464
if not all(k in result for k in required_fields):
5565
raise ValueError("Missing required fields in LLM response")
5666
return result
5767
except (json.JSONDecodeError, ValueError) as e:
5868
# LLMがJSON以外を返した場合のフォールバック
59-
print(f"JSON parse error: {e}. Returning fallback response.")
69+
print(f"❌ JSON parse error: {e}")
70+
print(f"📝 LLM Response (first 500 chars): {response[:500]}")
71+
print(f"📝 LLM Response (last 500 chars): {response[-500:]}")
72+
print("⚠️ Returning fallback response.")
6073
return {
6174
"title": "演習生成エラー",
6275
"difficulty": "beginner",
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import { useRouter } from "next/navigation";
5+
import { withAuth } from "@/lib/auth/withAuth";
6+
import type { QuestGenerationResponse } from "@/features/exercise/types";
7+
import { QuestResultHeader } from "@/features/exercise/components/QuestResultHeader";
8+
import { QuestObjectives } from "@/features/exercise/components/QuestObjectives";
9+
import { QuestStepList } from "@/features/exercise/components/QuestStepList";
10+
import { QuestResources } from "@/features/exercise/components/QuestResources";
11+
12+
function ExerciseGenerateResultPage() {
13+
const router = useRouter();
14+
const [quest, setQuest] = useState<QuestGenerationResponse | null>(null);
15+
const [error, setError] = useState<string | null>(null);
16+
17+
useEffect(() => {
18+
const loadQuest = () => {
19+
const raw = sessionStorage.getItem("questGenerationResult");
20+
if (!raw) {
21+
setError(
22+
"生成結果が見つかりません。演習生成ページからやり直してください。",
23+
);
24+
return;
25+
}
26+
try {
27+
setQuest(JSON.parse(raw) as QuestGenerationResponse);
28+
} catch {
29+
setError(
30+
"データの読み込みに失敗しました。演習生成ページからやり直してください。",
31+
);
32+
}
33+
};
34+
35+
loadQuest();
36+
}, []);
37+
38+
// エラー状態
39+
if (error) {
40+
return (
41+
<div className="min-h-screen bg-[#FDFEF0] flex items-center justify-center">
42+
<div className="text-center max-w-md mx-auto px-6">
43+
<div
44+
className="bg-white border-4 border-red-500 p-8 mb-6"
45+
style={{ boxShadow: "6px 6px 0 #000" }}
46+
>
47+
<p className="text-red-600 font-bold text-xl mb-3">⚠️ エラー</p>
48+
<p className="text-[#14532D]">{error}</p>
49+
</div>
50+
<button
51+
onClick={() => router.push("/exercises")}
52+
className="inline-flex items-center gap-2 px-6 py-3 bg-[#FDFEF0] border-4 border-[#14532D] text-[#14532D] font-bold hover:bg-[#4ADE80] transition-colors"
53+
style={{ boxShadow: "4px 4px 0 #000" }}
54+
>
55+
<span className="text-xl"></span>
56+
演習生成ページへ戻る
57+
</button>
58+
</div>
59+
</div>
60+
);
61+
}
62+
63+
// ローディング状態
64+
if (!quest) {
65+
return (
66+
<div className="min-h-screen bg-[#FDFEF0] flex items-center justify-center">
67+
<div className="text-[#14532D] text-2xl font-bold">Loading...</div>
68+
</div>
69+
);
70+
}
71+
72+
return (
73+
<div className="min-h-screen bg-[#FDFEF0] p-8">
74+
<div className="max-w-5xl mx-auto">
75+
{/* 戻るボタン */}
76+
<div className="mb-8">
77+
<button
78+
onClick={() => router.push("/exercises")}
79+
className="inline-flex items-center gap-2 px-6 py-3 bg-[#FDFEF0] border-4 border-[#14532D] text-[#14532D] font-bold hover:bg-[#4ADE80] transition-colors mb-6"
80+
style={{ boxShadow: "4px 4px 0 #000" }}
81+
>
82+
<span className="text-xl"></span>
83+
<span>戻る</span>
84+
</button>
85+
86+
{/* ヘッダー */}
87+
<QuestResultHeader quest={quest} />
88+
</div>
89+
90+
{/* 学習目標 */}
91+
<QuestObjectives objectives={quest.learning_objectives} />
92+
93+
{/* ステップ一覧 */}
94+
<QuestStepList steps={quest.steps} />
95+
96+
{/* 参考リソース */}
97+
<QuestResources resources={quest.resources} />
98+
99+
{/* 底部アクション */}
100+
<div className="flex flex-wrap gap-4 mt-8">
101+
<button
102+
onClick={() => {
103+
sessionStorage.removeItem("questGenerationResult");
104+
router.push("/exercises");
105+
}}
106+
className="inline-flex items-center gap-2 px-6 py-3 bg-[#4ADE80] border-4 border-[#14532D] text-[#14532D] font-bold hover:bg-[#86EFAC] transition-colors"
107+
style={{ boxShadow: "4px 4px 0 #000" }}
108+
>
109+
<span>🔄</span>
110+
もう一度生成する
111+
</button>
112+
<button
113+
onClick={() => router.push("/exercises")}
114+
className="inline-flex items-center gap-2 px-6 py-3 bg-[#FDFEF0] border-4 border-[#14532D] text-[#14532D] font-bold hover:bg-[#4ADE80] transition-colors"
115+
style={{ boxShadow: "4px 4px 0 #000" }}
116+
>
117+
<span>🎮</span>
118+
演習一覧へ
119+
</button>
120+
</div>
121+
</div>
122+
</div>
123+
);
124+
}
125+
126+
export default withAuth(ExerciseGenerateResultPage);

frontend/src/features/dashboard/components/AcquiredBadges.tsx

Lines changed: 79 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
1-
'use client';
1+
"use client";
22

3-
import Image from 'next/image';
3+
import Image from "next/image";
44

55
// カテゴリ色定義
66
const CATEGORY_COLORS: Record<string, string> = {
77
web: "#55aaff",
88
ai: "#e8b849",
99
security: "#e85555",
1010
infra: "#55cc55",
11-
design: "#cc66dd"
11+
design: "#cc66dd",
1212
};
1313

1414
interface Badge {
1515
id: number;
1616
name: string;
17-
type: 'trophy' | 'rank';
17+
type: "trophy" | "rank";
1818
image: string;
1919
category?: keyof typeof CATEGORY_COLORS;
2020
rankLevel?: number;
@@ -23,12 +23,58 @@ interface Badge {
2323

2424
export function AcquiredBadges() {
2525
const badges: Badge[] = [
26-
{ id: 1, name: 'Trophy', type: 'trophy', image: '/images/badges/Trophy.png', sortOrder: 1 },
27-
{ id: 2, name: 'AI Basic', type: 'rank', image: '/images/ranks/rank_tree_1.png', category: 'ai', rankLevel: 1, sortOrder: 2 },
28-
{ id: 3, name: 'Web Basic', type: 'rank', image: '/images/ranks/rank_tree_1.png', category: 'web', rankLevel: 1, sortOrder: 2 },
29-
{ id: 4, name: 'Security Basic', type: 'rank', image: '/images/ranks/rank_tree_1.png', category: 'security', rankLevel: 1, sortOrder: 2 },
30-
{ id: 5, name: 'AI Intermediate', type: 'rank', image: '/images/ranks/rank_tree_3.png', category: 'ai', rankLevel: 3, sortOrder: 3 },
31-
{ id: 6, name: 'Web Advanced', type: 'rank', image: '/images/ranks/rank_tree_5.png', category: 'web', rankLevel: 5, sortOrder: 4 },
26+
{
27+
id: 1,
28+
name: "Trophy",
29+
type: "trophy",
30+
image: "/images/badges/Trophy.png",
31+
sortOrder: 1,
32+
},
33+
{
34+
id: 2,
35+
name: "AI Basic",
36+
type: "rank",
37+
image: "/images/ranks/rank_tree_1.png",
38+
category: "ai",
39+
rankLevel: 1,
40+
sortOrder: 2,
41+
},
42+
{
43+
id: 3,
44+
name: "Web Basic",
45+
type: "rank",
46+
image: "/images/ranks/rank_tree_1.png",
47+
category: "web",
48+
rankLevel: 1,
49+
sortOrder: 2,
50+
},
51+
{
52+
id: 4,
53+
name: "Security Basic",
54+
type: "rank",
55+
image: "/images/ranks/rank_tree_1.png",
56+
category: "security",
57+
rankLevel: 1,
58+
sortOrder: 2,
59+
},
60+
{
61+
id: 5,
62+
name: "AI Intermediate",
63+
type: "rank",
64+
image: "/images/ranks/rank_tree_3.png",
65+
category: "ai",
66+
rankLevel: 3,
67+
sortOrder: 3,
68+
},
69+
{
70+
id: 6,
71+
name: "Web Advanced",
72+
type: "rank",
73+
image: "/images/ranks/rank_tree_5.png",
74+
category: "web",
75+
rankLevel: 5,
76+
sortOrder: 4,
77+
},
3278
];
3379

3480
// ソート: トロフィー → 初級 → 中級 → 上級
@@ -44,33 +90,34 @@ export function AcquiredBadges() {
4490
<h3 className="mb-4 text-2xl font-bold tracking-widest text-[#2C5F2D] [text-shadow:2px_2px_0_#a3e635]">
4591
獲得バッチ
4692
</h3>
47-
48-
<div
93+
94+
<div
4995
className="bg-[#FDFEF0] px-6 py-6 overflow-x-auto"
5096
style={{
5197
border: "4px solid #2C5F2D",
5298
boxShadow: "8px 8px 0 #2C5F2D",
53-
imageRendering: "pixelated"
99+
imageRendering: "pixelated",
54100
}}
55101
>
56102
{/* Badges in horizontal scroll */}
57103
<div className="flex gap-6 min-w-max items-end pb-4">
58104
{sortedBadges.map((badge, index) => {
59-
const isTrophy = badge.type === 'trophy';
60-
const sizeClass = isTrophy ? 'h-80 w-80' : 'h-60 w-60';
105+
const isTrophy = badge.type === "trophy";
106+
const sizeClass = isTrophy ? "h-80 w-80" : "h-60 w-60";
61107
const animationDelay = index * 0.2;
62-
const backgroundColor = badge.category ? CATEGORY_COLORS[badge.category] : 'transparent';
63-
108+
64109
return (
65-
<div
66-
key={badge.id}
110+
<div
111+
key={badge.id}
67112
className="flex flex-col items-center gap-2 group"
68113
>
69-
<span className="text-xl font-bold text-[#2C5F2D] opacity-0 group-hover:opacity-100 group-hover:animate-bounce transition-opacity"></span>
70-
<div
114+
<span className="text-xl font-bold text-[#2C5F2D] opacity-0 group-hover:opacity-100 group-hover:animate-bounce transition-opacity">
115+
116+
</span>
117+
<div
71118
className={`relative ${sizeClass} filter drop-shadow-[4px_4px_0_rgba(0,0,0,0.2)]`}
72119
style={{
73-
animation: 'float 2s steps(2) infinite',
120+
animation: "float 2s steps(2) infinite",
74121
animationDelay: `${animationDelay}s`,
75122
}}
76123
>
@@ -80,11 +127,17 @@ export function AcquiredBadges() {
80127
{[...Array(16)].map((_, i) => {
81128
const sparkleColor = CATEGORY_COLORS[badge.category!];
82129
// 不規則な遅延とポジション
83-
const delays = [0, 0.3, 0.7, 1.1, 0.5, 0.9, 1.3, 0.2, 0.8, 1.0, 0.4, 1.2, 0.6, 1.4, 0.1, 1.5];
84-
const positions = [5, 15, 25, 35, 45, 55, 65, 75, 10, 20, 30, 40, 50, 60, 70, 80];
130+
const delays = [
131+
0, 0.3, 0.7, 1.1, 0.5, 0.9, 1.3, 0.2, 0.8, 1.0, 0.4,
132+
1.2, 0.6, 1.4, 0.1, 1.5,
133+
];
134+
const positions = [
135+
5, 15, 25, 35, 45, 55, 65, 75, 10, 20, 30, 40, 50, 60,
136+
70, 80,
137+
];
85138
const delay = delays[i];
86139
const leftPosition = positions[i];
87-
140+
88141
return (
89142
<div
90143
key={i}
@@ -95,7 +148,7 @@ export function AcquiredBadges() {
95148
bottom: 0,
96149
opacity: 0.7,
97150
boxShadow: `0 0 10px ${sparkleColor}`,
98-
animation: 'sparkle-rise 3s ease-in-out infinite',
151+
animation: "sparkle-rise 3s ease-in-out infinite",
99152
animationDelay: `${delay}s`,
100153
zIndex: 1,
101154
}}

0 commit comments

Comments
 (0)