feat: Initial commit for Saqel Platform architecture, backend, Docker, and documentation

This commit is contained in:
Hamza-Ayed
2026-08-26 15:45:55 +03:00
commit d53b64500a
177 changed files with 19909 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace App\Services;
use App\Contracts\AiService;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class GeminiService implements AiService
{
private const ANALYSIS_SCHEMA_HINT = <<<'PROMPT'
أنت معلم خبير خاص بمنهج الثانوية العامة الأردني (التوجيهي). حلل بيانات أداء الطالب التالية
وأعد استجابة JSON فقط بالحقول التالية بالضبط:
{
"weak_concepts": [{"name": "...", "reason": "...", "severity": "high|medium|low"}],
"remedial_lesson_ids": [أرقام صحيحة],
"recommended_actions": ["..."],
"motivation_note": "رسالة تحفيزية قصيرة بالعربية",
"summary": "ملخص من سطرين بالعربية"
}
لا تكتب أي شيء خارج JSON.
PROMPT;
/**
* @param array<string, mixed> $performance
* @return array<string, mixed>
*
* @throws ConnectionException
*/
public function analyzeStudentPerformance(array $performance): array
{
$text = $this->generate(self::ANALYSIS_SCHEMA_HINT."\n\nبيانات الطالب:\n".json_encode($performance, JSON_UNESCAPED_UNICODE));
return $this->decodeJson($text);
}
/**
* @param array<string, mixed> $activity
* @return array<string, mixed>
*
* @throws ConnectionException
*/
public function generateParentWeeklyReport(array $activity): array
{
$prompt = <<<'PROMPT'
أنت مستشار تعليمي. اكتب تقريراً أسبوعياً لولي أمر طالب توجيهي بناءً على البيانات التالية.
أعد JSON فقط بهذا الشكل:
{"headline": "...", "progress_note": "...", "weak_points": ["..."], "home_advice": ["..."]}
استخدم العربية الواضحة دون مصطلحات تقنية.
PROMPT;
$text = $this->generate($prompt."\n\nبيانات النشاط:\n".json_encode($activity, JSON_UNESCAPED_UNICODE));
return $this->decodeJson($text);
}
/**
* @throws ConnectionException
*/
private function generate(string $prompt): string
{
$config = config('services.gemini');
$model = is_array($config) ? ($config['model'] ?? 'gemini-2.5-flash-lite') : 'gemini-2.5-flash-lite';
$response = Http::withHeaders([
'x-goog-api-key' => is_array($config) ? (string) $config['key'] : '',
])
->timeout(is_array($config) ? (int) ($config['timeout'] ?? 30) : 30)
->retry(2, 500)
->post(rtrim((string) (is_array($config) ? $config['base_url'] : ''), '/')."/models/{$model}:generateContent", [
'contents' => [
['parts' => [['text' => $prompt]]],
],
'generationConfig' => [
'temperature' => 0.4,
'responseMimeType' => 'application/json',
],
]);
if ($response->failed()) {
Log::warning('Gemini request failed', ['status' => $response->status(), 'body' => mb_substr($response->body(), 0, 500)]);
throw new ConnectionException('Gemini API request failed.');
}
/** @var array{candidates?: list<array{content?: array{parts?: list<array{text?: string}}>}}> $payload */
$payload = $response->json();
return $payload['candidates'][0]['content']['parts'][0]['text'] ?? '{}';
}
/**
* @return array<string, mixed>
*/
private function decodeJson(string $text): array
{
/** @var array<string, mixed> $decoded */
$decoded = json_decode(trim($text), true);
if (! is_array($decoded)) {
throw new \RuntimeException('Gemini returned invalid JSON payload.');
}
return $decoded;
}
}
+167
View File
@@ -0,0 +1,167 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
use Exception;
class NabehOtpService
{
protected string $authUrl;
protected string $sendUrl;
protected ?string $email;
protected ?string $password;
public function __construct()
{
$this->authUrl = config('services.nabeh.auth_url', 'https://nabeh.intaleqapp.com/api/auth/login');
$this->sendUrl = config('services.nabeh.send_url', 'https://nabeh.intaleqapp.com/api/otp/send');
$this->email = config('services.nabeh.email', env('NABEH_EMAIL'));
$this->password = config('services.nabeh.password', env('NABEH_PASSWORD'));
}
/**
* Retrieve Nabeh JWT Bearer Token, caching it in Redis for 24 hours.
*/
public function getBearerToken(): ?string
{
// 1. Try fetching from Redis first
try {
$cachedToken = Redis::get('nabeh_bearer_token');
if ($cachedToken) {
return (string)$cachedToken;
}
} catch (Exception $e) {
Log::warning("⚠️ [Nabeh Auth Redis] Error reading token: " . $e->getMessage());
}
// 2. Token not cached, authenticate via Nabeh Login API
if (!$this->email || !$this->password) {
Log::error("❌ [Nabeh Auth] Missing NABEH_EMAIL or NABEH_PASSWORD environment variables.");
return null;
}
try {
$response = Http::timeout(10)->post($this->authUrl, [
'email' => $this->email,
'password' => $this->password,
]);
if ($response->successful()) {
$decoded = $response->json();
$token = $decoded['token'] ?? $decoded['message']['token'] ?? $decoded['jwt'] ?? $decoded['access_token'] ?? null;
if ($token) {
// 3. Cache token in Redis for 24h
try {
Redis::setex('nabeh_bearer_token', 86400, (string)$token);
Log::info("[Nabeh Auth] Token cached in Redis successfully.");
} catch (Exception $e) {
Log::warning("⚠️ [Nabeh Auth Redis Cache Save] Error saving token: " . $e->getMessage());
}
return (string)$token;
}
Log::error("❌ [Nabeh Auth Login Failed] Response without token: " . $response->body());
} else {
Log::error("❌ [Nabeh Auth Login Failed] Status: " . $response->status() . " | Body: " . $response->body());
}
} catch (Exception $e) {
Log::error("❌ [Nabeh Auth Exception] " . $e->getMessage());
}
return null;
}
/**
* Send OTP via Nabeh JWT Auth Gateway (WhatsApp Image/Text OTP)
*
* @param string $receiver Recipient phone number
* @param string $otp 4-6 digit verification code
* @param string $method image | text | voice
* @param string $appName Application name to appear in the message
* @return bool
*/
public function sendOtp(string $receiver, string $otp, string $method = 'image', string $appName = 'منصة صَقِل'): bool
{
$bearerToken = $this->getBearerToken();
if (!$bearerToken) {
Log::error("⚠️ [Nabeh OTP] Failed to obtain dynamic JWT Bearer token.");
return false;
}
// Strip symbols from phone
$phoneRaw = preg_replace('/\D+/', '', $receiver);
// Type mapping (Image OTP card is default for Nabeh)
$type = in_array($method, ['text', 'voice', 'image'], true) ? $method : 'image';
// First attempt with the chosen type
$success = $this->attemptSend($phoneRaw, $type, $otp, $appName, $bearerToken);
if ($success) {
return true;
}
// Fallback: if image failed, retry with text
if ($type === 'image') {
Log::info("ℹ️ [Nabeh OTP Fallback] Image failed, retrying with text type...");
return $this->attemptSend($phoneRaw, 'text', $otp, $appName, $bearerToken);
}
return false;
}
/**
* Internal helper: single OTP send attempt to Nabeh
*/
protected function attemptSend(string $phone, string $type, string $otp, string $appName, string $bearerToken): bool
{
$payload = [
'phone' => $phone,
'type' => $type,
'code' => $otp,
'message' => "رمز التحقق الخاص بك لمنصة {$appName} هو: *{code}* \n الرجاء عدم مشاركته مع أي شخص لحماية حسابك.",
];
try {
$response = Http::withToken($bearerToken)
->timeout(10)
->post($this->sendUrl, $payload);
Log::info("ℹ️ [Nabeh OTP Response type={$type}] " . $response->body());
if ($response->successful()) {
$decoded = $response->json();
if ($decoded) {
$statusStr = strtolower((string)($decoded['status'] ?? ''));
$msgStr = strtolower((string)($decoded['message'] ?? ''));
$errStr = strtolower((string)($decoded['error'] ?? ''));
if (
!empty($decoded['success']) ||
in_array($statusStr, ['success', 'ok', 'true', '200', 'sent', 'queued', '1'], true) ||
($decoded['status'] ?? false) === true ||
($decoded['code'] ?? 0) === 200 ||
!empty($decoded['message_id']) ||
!empty($decoded['id']) ||
!empty($decoded['token']) ||
str_contains($msgStr, 'success') ||
str_contains($msgStr, 'sent') ||
str_contains($msgStr, 'تم') ||
str_contains($errStr, 'via gateway')
) {
return true;
}
}
Log::warning("⚠️ [Nabeh OTP Response not matching success] " . $response->body());
} else {
Log::error("❌ [Nabeh OTP Request Failed] Status: " . $response->status() . " | Body: " . $response->body());
}
} catch (Exception $e) {
Log::error("❌ [Nabeh OTP Exception] " . $e->getMessage());
}
return false;
}
}
@@ -0,0 +1,182 @@
<?php
namespace App\Services;
use App\Models\Answer;
use App\Models\ConceptMastery;
use App\Models\Lesson;
use App\Models\LessonProgress;
use App\Models\Question;
use App\Models\QuestionOption;
use App\Models\Quiz;
use App\Models\QuizAttempt;
use App\Models\User;
use Illuminate\Support\Collection;
class ProgressTrackingService
{
private const COMPLETION_THRESHOLD = 95.0;
private const MASTERY_DECAY = 0.7;
public function heartbeat(User $student, Lesson $lesson, int $positionSeconds): LessonProgress
{
$duration = max(1, $lesson->duration_seconds);
$position = max(0, min($positionSeconds, $lesson->duration_seconds));
$watched = round(($position / $duration) * 100, 2);
/** @var LessonProgress $progress */
$progress = LessonProgress::updateOrCreate(
['user_id' => $student->id, 'lesson_id' => $lesson->id],
[
'last_position_seconds' => $position,
'watched_percentage' => (int) min(100, $watched),
'completed_at' => $watched >= self::COMPLETION_THRESHOLD ? now() : null,
]
);
return $progress;
}
/**
* @param array<int, array{question_id: int, selected_option_id?: ?int, time_spent_seconds?: int}> $submittedAnswers
* @return array<string, mixed>
*/
public function recordQuizAttempt(User $student, Quiz $quiz, array $submittedAnswers): array
{
/** @var Collection<int, Question> $questions */
$questions = Question::with('options')
->where('quiz_id', $quiz->id)
->whereIn('id', collect($submittedAnswers)->pluck('question_id'))
->get()
->keyBy('id');
/** @var QuizAttempt $attempt */
$attempt = $quiz->attempts()->create([
'user_id' => $student->id,
'score' => 0,
]);
$correctCount = 0;
$results = [];
foreach ($submittedAnswers as $entry) {
/** @var Question|null $question */
$question = $questions->get($entry['question_id']);
if ($question === null) {
continue;
}
$selectedOptionId = $entry['selected_option_id'] ?? null;
/** @var QuestionOption|null $option */
$option = $selectedOptionId !== null
? $question->options->firstWhere('id', $selectedOptionId)
: null;
$isCorrect = $option !== null && $option->is_correct;
if ($isCorrect) {
$correctCount++;
}
$attempt->answers()->create([
'question_id' => $question->id,
'selected_option_id' => $option?->id,
'is_correct' => $isCorrect,
'time_spent_seconds' => $entry['time_spent_seconds'] ?? 0,
]);
if ($question->concept_id !== null) {
$this->updateMastery($student->id, (int) $question->concept_id, $isCorrect);
}
$results[] = [
'question_id' => $question->id,
'is_correct' => $isCorrect,
'explanation' => $question->explanation,
];
}
$total = count($results);
$score = $total > 0 ? (int) round(($correctCount / $total) * 100) : 0;
$attempt->update(['score' => $score]);
return [
'attempt_id' => $attempt->id,
'score' => $score,
'total_questions' => $total,
'correct_answers' => $correctCount,
'results' => $results,
];
}
private function updateMastery(int $userId, int $conceptId, bool $isCorrect): ConceptMastery
{
/** @var ConceptMastery $mastery */
$mastery = ConceptMastery::firstOrNew(['user_id' => $userId, 'concept_id' => $conceptId]);
if (! $mastery->exists) {
$mastery->correct_count = 0;
$mastery->wrong_count = 0;
$mastery->mastery_score = 0;
}
if ($mastery->correct_count + $mastery->wrong_count === 0) {
$mastery->mastery_score = $isCorrect ? 100 : 0;
} else {
$previous = (float) $mastery->mastery_score / 100;
$outcome = $isCorrect ? 1.0 : 0.0;
$mastery->mastery_score = round((($previous * self::MASTERY_DECAY) + ($outcome * (1 - self::MASTERY_DECAY))) * 100, 2);
}
$isCorrect ? $mastery->correct_count++ : $mastery->wrong_count++;
$mastery->save();
return $mastery;
}
/**
* Collect the student's aggregated performance snapshot for AI analysis.
*
* @return array<string, mixed>
*/
public function buildPerformanceSnapshot(User $student): array
{
$masteryRows = $student->conceptMastery()
->with('concept:id,name,remedial_lesson_id')
->orderBy('mastery_score')
->limit(15)
->get();
$recentWrongAnswers = Answer::query()
->where('is_correct', false)
->whereHas('attempt', fn ($query) => $query->where('user_id', $student->id))
->with('question:id,body,concept_id')
->latest()
->limit(10)
->get();
$progressStats = $student->lessonProgress()
->selectRaw('COUNT(*) as lessons_started, COALESCE(AVG(watched_percentage), 0) as avg_watched')
->first();
return [
'student_grade' => $student->grade,
'track' => $student->track,
'lessons_started' => (int) ($progressStats?->lessons_started ?? 0),
'average_watch_percentage' => round((float) ($progressStats?->avg_watched ?? 0), 1),
'concept_mastery' => $masteryRows->map(fn (ConceptMastery $row): array => [
'concept_id' => $row->concept_id,
'name' => $row->concept?->name,
'mastery_score' => (float) $row->mastery_score,
'correct' => $row->correct_count,
'wrong' => $row->wrong_count,
])->all(),
'recent_wrong_answers' => $recentWrongAnswers->map(fn (Answer $answer): array => [
'question_body' => $answer->question?->body,
])->all(),
];
}
}