Spaces:
Sleeping
Sleeping
| """``/api/ai/recommend`` — AI-powered next-challenge recommendation. | |
| Analyzes the user's completion history and picks the challenge type they've | |
| done least, then selects a random challenge from that pool. Returns the full | |
| set of parameters the front-end needs to start a ``TrainingSession``. | |
| """ | |
| import random | |
| from typing import Optional | |
| import httpx | |
| from fastapi import APIRouter, Depends | |
| from pydantic import BaseModel | |
| from app.core.auth import get_current_user | |
| from app.core.config import SUPABASE_URL, SUPABASE_ANON_KEY | |
| from app.services.supabase_service import supabase_headers | |
| router = APIRouter() | |
| class AiRecommendResponse(BaseModel): | |
| categoryId: str | |
| pathId: str | |
| moduleId: str | |
| teamRole: str | |
| challengeId: str | |
| title: str | |
| reason: str # short AI-style justification | |
| # All five challenge types with their table, front-end params, and | |
| # the category key used in ``user_completions``. | |
| _CHALLENGE_TYPES: list[dict] = [ | |
| { | |
| "table": "encryption_challenges", | |
| "team": "red", | |
| "categoryId": "encryption", | |
| "pathId": "cryptography", | |
| "moduleId": "crypto", | |
| "completion_category": "crypto", | |
| }, | |
| { | |
| "table": "code_fixing_challenges", | |
| "team": "blue", | |
| "categoryId": "code_analysis", | |
| "pathId": "web-security", | |
| "moduleId": "code-fixing", | |
| "completion_category": "code-fixing", | |
| }, | |
| { | |
| "table": "log_analysis_challenges", | |
| "team": "blue", | |
| "categoryId": "log_analysis", | |
| "pathId": "log-analysis", | |
| "moduleId": "log-analysis", | |
| "completion_category": "log-analysis", | |
| }, | |
| { | |
| "table": "vulnerability_hunter_challenges", | |
| "team": "blue", | |
| "categoryId": "vulnerability_hunter", | |
| "pathId": "secure-coding", | |
| "moduleId": "vulnerability-hunter", | |
| "completion_category": "vulnerability-hunter", | |
| }, | |
| { | |
| "table": "web_exploitation_challenges", | |
| "team": "red", | |
| "categoryId": "web_exploitation", | |
| "pathId": "xss", | |
| "moduleId": "web-exploitation", | |
| "completion_category": "web-exploitation", | |
| }, | |
| ] | |
| # Short AI-sounding reasons keyed by completion_category | |
| _REASONS: dict[str, list[str]] = { | |
| "crypto": [ | |
| "نقاط ضعفك في التشفير تحتاج تقوية — جرّب هذا التحدي لتصقل مهاراتك", | |
| "التشفير هو خط الدفاع الأول في الأمن السيبراني — حان وقت التمرّن", | |
| "مهاراتك في فك التشفير بحاجة إلى صقل — هذا التحدي سيساعدك", | |
| ], | |
| "code-fixing": [ | |
| "اكتشاف الثغرات في الكود مهارة لا غنى عنها — طوّرها بهذا التحدي", | |
| "المبرمجون المحترفون يقرؤون الكود بحثاً عن الثغرات — كن منهم", | |
| "هذا التحدي سيدربك على تحديد نقاط الضعف في التطبيقات", | |
| ], | |
| "log-analysis": [ | |
| "تحليل السجلات يكشف الهجمات قبل فوات الأوان — درّب عينك", | |
| "محللو SOC المتميزون يميّزون الأنماط في السجلات — تدرب هنا", | |
| "هذا التحدي يحاكي هجوماً حقيقياً — حلّله وكن جاهزاً", | |
| ], | |
| "vulnerability-hunter": [ | |
| "صائد الثغرات الحقيقي لا يفوته أي خلل — اختبر نفسك", | |
| "كلما اكتشفت ثغرات أكثر، كلما أصبحت أقوى — هذا هو تحديك التالي", | |
| "ثغرات اليوم هي ثغرات الغد — تدرب على اكتشافها الآن", | |
| ], | |
| "web-exploitation": [ | |
| "الهجمات الإلكترونية تستهدف الويب أولاً — كن مستعداً", | |
| "هذا التحدي يحاكي هجوماً حقيقياً على تطبيقات الويب", | |
| "الـ Bug Bounty يبدأ بفهم الثغرات — هذا هو تحديك التالي", | |
| ], | |
| } | |
| async def get_ai_recommendation(user: dict = Depends(get_current_user)): | |
| if not SUPABASE_URL or not SUPABASE_ANON_KEY: | |
| return {"error": "AI recommendation unavailable"} | |
| # 1. Get completions per category for this user | |
| completions = await _fetch_user_completions(user["user_id"]) | |
| if completions is None: | |
| completions = {} | |
| # 2. Sort challenge types by completion count (ascending) | |
| scored = [] | |
| for ct in _CHALLENGE_TYPES: | |
| cat = ct["completion_category"] | |
| count = completions.get(cat, 0) | |
| scored.append((count, ct)) | |
| scored.sort(key=lambda x: x[0]) | |
| # 3. Try each type from least-completed to most, pick one with available challenges | |
| for count, ct in scored: | |
| row = await _fetch_random_challenge(ct["table"], ct["team"]) | |
| if row is None: | |
| continue | |
| reason = _pick_reason(ct["completion_category"]) | |
| return AiRecommendResponse( | |
| categoryId=ct["categoryId"], | |
| pathId=ct["pathId"], | |
| moduleId=ct["moduleId"], | |
| teamRole=ct["team"], | |
| challengeId=row["id"], | |
| title=row.get("title", "Untitled Challenge"), | |
| reason=reason, | |
| ) | |
| return {"error": "لا توجد تحديات متاحة حالياً"} | |
| async def _fetch_user_completions(user_id: str) -> Optional[dict[str, int]]: | |
| """Return {category: count} for this user, or None on error.""" | |
| url = f"{SUPABASE_URL}/rest/v1/user_completions?user_id=eq.{user_id}&select=category" | |
| try: | |
| async with httpx.AsyncClient(timeout=10) as client: | |
| r = await client.get(url, headers=supabase_headers()) | |
| if r.status_code == 200: | |
| counts: dict[str, int] = {} | |
| for row in r.json(): | |
| cat = row.get("category", "") | |
| if cat: | |
| counts[cat] = counts.get(cat, 0) + 1 | |
| return counts | |
| except Exception as e: | |
| print(f"[ai_recommend] fetch completions error: {e}") | |
| return None | |
| async def _fetch_random_challenge(table: str, team_role: str) -> Optional[dict]: | |
| """Pick a random challenge from the given pool table.""" | |
| url = ( | |
| f"{SUPABASE_URL}/rest/v1/{table}" | |
| f"?team_role=eq.{team_role}&select=id,title" | |
| f"&limit=20&order=created_at.desc" | |
| ) | |
| try: | |
| async with httpx.AsyncClient(timeout=10) as client: | |
| r = await client.get(url, headers=supabase_headers()) | |
| if r.status_code == 200: | |
| rows = r.json() | |
| if rows: | |
| return random.choice(rows) | |
| except Exception as e: | |
| print(f"[ai_recommend] {table}: {e}") | |
| return None | |
| def _pick_reason(completion_category: str) -> str: | |
| reasons = _REASONS.get(completion_category) | |
| if not reasons: | |
| return "التحدي الأمثل لمستواك الحالي — جرّبه الآن" | |
| return random.choice(reasons) | |