feat: Initial commit for Saqel Platform architecture, backend, Docker, and documentation
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user