Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
8 changes: 7 additions & 1 deletion backend/app/dependencies/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,14 @@ def _decode_token(token: str) -> int:
detail="トークンの有効期限が切れています",
headers={"WWW-Authenticate": "Bearer"},
)
except jwt.InvalidSignatureError:
# 署名検証失敗(改ざん検出)
raise credentials_exception
except jwt.DecodeError:
# デコード失敗(不正なフォーマット)
raise credentials_exception
except jwt.PyJWTError:
# その他のJWTエラー
raise credentials_exception


Expand Down Expand Up @@ -101,4 +108,3 @@ def get_current_user(
headers={"WWW-Authenticate": "Bearer"},
)
return db_user

13 changes: 9 additions & 4 deletions backend/app/schemas/quest.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ class QuestStep(BaseModel):
"""演習ステップ"""

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


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

21 changes: 17 additions & 4 deletions backend/app/services/quest_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@

async def generate_handson_quest(
document_content: str,
user_rank: int,
user_rank: int = 0,
user_skills: str = "",
) -> dict:
"""
LLMを使用してハンズオン演習を生成

Args:
document_content: 学習対象のドキュメント
user_rank: ユーザーのランク(0-9)
user_rank: ユーザーのランク(0-9、デフォルト=0
user_skills: ユーザーの得意分野(オプション)

Returns:
Expand All @@ -46,17 +46,30 @@ async def generate_handson_quest(
# LLMに非同期で呼び出し (temperature=0.7で創造性を持たせる)
response = await invoke_llm(prompt=prompt, temperature=0.7)

# Markdownコードブロックを除去(LLMが```json ... ```で囲む場合がある)
cleaned_response = response.strip()
if cleaned_response.startswith("```json"):
cleaned_response = cleaned_response[7:] # "```json" を削除
elif cleaned_response.startswith("```"):
cleaned_response = cleaned_response[3:] # "```" を削除
if cleaned_response.endswith("```"):
cleaned_response = cleaned_response[:-3] # 末尾の "```" を削除
cleaned_response = cleaned_response.strip()

# JSONパース(エラーハンドリング付き)
try:
result = json.loads(response)
result = json.loads(cleaned_response)
# 必須フィールドの存在確認
required_fields = ["title", "difficulty", "steps"]
if not all(k in result for k in required_fields):
raise ValueError("Missing required fields in LLM response")
return result
Comment on lines 62 to 66
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.

必須フィールドチェックが title/difficulty/steps のみのため、LLMが estimated_time_minutes や learning_objectives を欠いたJSONを返すと、この関数は成功扱いで返却し、呼び出し側の QuestGenerationResponse 検証で 500 になり得ます。ここで QuestGenerationResponse の必須項目(estimated_time_minutes, learning_objectives, resources など)も検証するか、欠損時はデフォルト値補完/フォールバックに落とすようにして、500 を避けてください。

Copilot uses AI. Check for mistakes.
except (json.JSONDecodeError, ValueError) as e:
# LLMがJSON以外を返した場合のフォールバック
print(f"JSON parse error: {e}. Returning fallback response.")
print(f"❌ JSON parse error: {e}")
print(f"📝 LLM Response (first 500 chars): {response[:500]}")
print(f"📝 LLM Response (last 500 chars): {response[-500:]}")
print("⚠️ Returning fallback response.")
Comment on lines +69 to +72
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.

Quest生成失敗時に LLM のレスポンス(先頭/末尾500文字)をそのまま print しており、学習ドキュメント由来の機微情報がログに残る可能性があります。backend では他サービスが logging.getLogger(name) を使っているので(例: backend/app/services/rank_service.py)、本件も logger に寄せてレベル制御(debug のみ出力)し、必要なら内容をマスク/短縮した上で出力してください。

Copilot uses AI. Check for mistakes.
return {
"title": "演習生成エラー",
"difficulty": "beginner",
Expand Down
126 changes: 126 additions & 0 deletions frontend/src/app/exercises/generate/result/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"use client";

import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { withAuth } from "@/lib/auth/withAuth";
import type { QuestGenerationResponse } from "@/features/exercise/types";
import { QuestResultHeader } from "@/features/exercise/components/QuestResultHeader";
import { QuestObjectives } from "@/features/exercise/components/QuestObjectives";
import { QuestStepList } from "@/features/exercise/components/QuestStepList";
import { QuestResources } from "@/features/exercise/components/QuestResources";

function ExerciseGenerateResultPage() {
const router = useRouter();
const [quest, setQuest] = useState<QuestGenerationResponse | null>(null);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
const loadQuest = () => {
const raw = sessionStorage.getItem("questGenerationResult");
if (!raw) {
setError(
"生成結果が見つかりません。演習生成ページからやり直してください。",
);
return;
}
try {
setQuest(JSON.parse(raw) as QuestGenerationResponse);
Comment on lines +18 to +27
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.

sessionStorage のデータが無い/壊れている場合にエラー表示だけしてキーを残したままなので、ブラウザバック等で再度このページに来ると同じエラーが再現し続けます。raw が無い/JSON.parse に失敗したケースでは questGenerationResult を removeItem してからエラーメッセージを出す方が復帰しやすいです。

Copilot uses AI. Check for mistakes.
} catch {
setError(
"データの読み込みに失敗しました。演習生成ページからやり直してください。",
);
}
};

loadQuest();
}, []);

// エラー状態
if (error) {
return (
<div className="min-h-screen bg-[#FDFEF0] flex items-center justify-center">
<div className="text-center max-w-md mx-auto px-6">
<div
className="bg-white border-4 border-red-500 p-8 mb-6"
style={{ boxShadow: "6px 6px 0 #000" }}
>
<p className="text-red-600 font-bold text-xl mb-3">⚠️ エラー</p>
<p className="text-[#14532D]">{error}</p>
</div>
<button
onClick={() => router.push("/exercises")}
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"
style={{ boxShadow: "4px 4px 0 #000" }}
>
<span className="text-xl">←</span>
演習生成ページへ戻る
</button>
</div>
</div>
);
}

// ローディング状態
if (!quest) {
return (
<div className="min-h-screen bg-[#FDFEF0] flex items-center justify-center">
<div className="text-[#14532D] text-2xl font-bold">Loading...</div>
</div>
);
}

return (
<div className="min-h-screen bg-[#FDFEF0] p-8">
<div className="max-w-5xl mx-auto">
{/* 戻るボタン */}
<div className="mb-8">
<button
onClick={() => router.push("/exercises")}
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"
style={{ boxShadow: "4px 4px 0 #000" }}
>
<span className="text-xl">←</span>
<span>戻る</span>
</button>

{/* ヘッダー */}
<QuestResultHeader quest={quest} />
</div>

{/* 学習目標 */}
<QuestObjectives objectives={quest.learning_objectives} />

{/* ステップ一覧 */}
<QuestStepList steps={quest.steps} />

{/* 参考リソース */}
<QuestResources resources={quest.resources} />

{/* 底部アクション */}
<div className="flex flex-wrap gap-4 mt-8">
<button
onClick={() => {
sessionStorage.removeItem("questGenerationResult");
router.push("/exercises");
}}
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"
style={{ boxShadow: "4px 4px 0 #000" }}
>
<span>🔄</span>
もう一度生成する
</button>
<button
onClick={() => router.push("/exercises")}
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"
style={{ boxShadow: "4px 4px 0 #000" }}
>
<span>🎮</span>
演習一覧へ
</button>
</div>
</div>
</div>
);
}

export default withAuth(ExerciseGenerateResultPage);
105 changes: 79 additions & 26 deletions frontend/src/features/dashboard/components/AcquiredBadges.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
'use client';
"use client";

import Image from 'next/image';
import Image from "next/image";

// カテゴリ色定義
const CATEGORY_COLORS: Record<string, string> = {
web: "#55aaff",
ai: "#e8b849",
security: "#e85555",
infra: "#55cc55",
design: "#cc66dd"
design: "#cc66dd",
};

interface Badge {
id: number;
name: string;
type: 'trophy' | 'rank';
type: "trophy" | "rank";
image: string;
category?: keyof typeof CATEGORY_COLORS;
rankLevel?: number;
Expand All @@ -23,12 +23,58 @@ interface Badge {

export function AcquiredBadges() {
const badges: Badge[] = [
{ id: 1, name: 'Trophy', type: 'trophy', image: '/images/badges/Trophy.png', sortOrder: 1 },
{ id: 2, name: 'AI Basic', type: 'rank', image: '/images/ranks/rank_tree_1.png', category: 'ai', rankLevel: 1, sortOrder: 2 },
{ id: 3, name: 'Web Basic', type: 'rank', image: '/images/ranks/rank_tree_1.png', category: 'web', rankLevel: 1, sortOrder: 2 },
{ id: 4, name: 'Security Basic', type: 'rank', image: '/images/ranks/rank_tree_1.png', category: 'security', rankLevel: 1, sortOrder: 2 },
{ id: 5, name: 'AI Intermediate', type: 'rank', image: '/images/ranks/rank_tree_3.png', category: 'ai', rankLevel: 3, sortOrder: 3 },
{ id: 6, name: 'Web Advanced', type: 'rank', image: '/images/ranks/rank_tree_5.png', category: 'web', rankLevel: 5, sortOrder: 4 },
{
id: 1,
name: "Trophy",
type: "trophy",
image: "/images/badges/Trophy.png",
sortOrder: 1,
},
{
id: 2,
name: "AI Basic",
type: "rank",
image: "/images/ranks/rank_tree_1.png",
category: "ai",
rankLevel: 1,
sortOrder: 2,
},
{
id: 3,
name: "Web Basic",
type: "rank",
image: "/images/ranks/rank_tree_1.png",
category: "web",
rankLevel: 1,
sortOrder: 2,
},
{
id: 4,
name: "Security Basic",
type: "rank",
image: "/images/ranks/rank_tree_1.png",
category: "security",
rankLevel: 1,
sortOrder: 2,
},
{
id: 5,
name: "AI Intermediate",
type: "rank",
image: "/images/ranks/rank_tree_3.png",
category: "ai",
rankLevel: 3,
sortOrder: 3,
},
{
id: 6,
name: "Web Advanced",
type: "rank",
image: "/images/ranks/rank_tree_5.png",
category: "web",
rankLevel: 5,
sortOrder: 4,
},
];

// ソート: トロフィー → 初級 → 中級 → 上級
Expand All @@ -44,33 +90,34 @@ export function AcquiredBadges() {
<h3 className="mb-4 text-2xl font-bold tracking-widest text-[#2C5F2D] [text-shadow:2px_2px_0_#a3e635]">
獲得バッチ
</h3>
<div

<div
className="bg-[#FDFEF0] px-6 py-6 overflow-x-auto"
style={{
border: "4px solid #2C5F2D",
boxShadow: "8px 8px 0 #2C5F2D",
imageRendering: "pixelated"
imageRendering: "pixelated",
}}
>
{/* Badges in horizontal scroll */}
<div className="flex gap-6 min-w-max items-end pb-4">
{sortedBadges.map((badge, index) => {
const isTrophy = badge.type === 'trophy';
const sizeClass = isTrophy ? 'h-80 w-80' : 'h-60 w-60';
const isTrophy = badge.type === "trophy";
const sizeClass = isTrophy ? "h-80 w-80" : "h-60 w-60";
const animationDelay = index * 0.2;
const backgroundColor = badge.category ? CATEGORY_COLORS[badge.category] : 'transparent';


return (
<div
key={badge.id}
<div
key={badge.id}
className="flex flex-col items-center gap-2 group"
>
<span className="text-xl font-bold text-[#2C5F2D] opacity-0 group-hover:opacity-100 group-hover:animate-bounce transition-opacity">▼</span>
<div
<span className="text-xl font-bold text-[#2C5F2D] opacity-0 group-hover:opacity-100 group-hover:animate-bounce transition-opacity">
</span>
<div
className={`relative ${sizeClass} filter drop-shadow-[4px_4px_0_rgba(0,0,0,0.2)]`}
style={{
animation: 'float 2s steps(2) infinite',
animation: "float 2s steps(2) infinite",
animationDelay: `${animationDelay}s`,
}}
>
Expand All @@ -80,11 +127,17 @@ export function AcquiredBadges() {
{[...Array(16)].map((_, i) => {
const sparkleColor = CATEGORY_COLORS[badge.category!];
// 不規則な遅延とポジション
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];
const positions = [5, 15, 25, 35, 45, 55, 65, 75, 10, 20, 30, 40, 50, 60, 70, 80];
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,
];
const positions = [
5, 15, 25, 35, 45, 55, 65, 75, 10, 20, 30, 40, 50, 60,
70, 80,
];
const delay = delays[i];
const leftPosition = positions[i];

return (
<div
key={i}
Expand All @@ -95,7 +148,7 @@ export function AcquiredBadges() {
bottom: 0,
opacity: 0.7,
boxShadow: `0 0 10px ${sparkleColor}`,
animation: 'sparkle-rise 3s ease-in-out infinite',
animation: "sparkle-rise 3s ease-in-out infinite",
animationDelay: `${delay}s`,
zIndex: 1,
}}
Expand Down
Loading
Loading