feat: Initial commit for Saqel Platform architecture, backend, Docker, and documentation
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Contracts;
|
||||
|
||||
interface AiService
|
||||
{
|
||||
/**
|
||||
* Analyze a student's performance data and return a structured remediation plan.
|
||||
*
|
||||
* @param array<string, mixed> $performance
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function analyzeStudentPerformance(array $performance): array;
|
||||
|
||||
/**
|
||||
* Generate a parent-friendly weekly summary from the student's activity data.
|
||||
*
|
||||
* @param array<string, mixed> $activity
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function generateParentWeeklyReport(array $activity): array;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class GuardianSummaryController extends Controller
|
||||
{
|
||||
public function show(Request $request, User $student): JsonResponse
|
||||
{
|
||||
/** @var User $guardian */
|
||||
$guardian = $request->user();
|
||||
|
||||
abort_unless($guardian->students()->whereKey($student->id)->exists(), 403);
|
||||
|
||||
$progress = $student->lessonProgress()
|
||||
->with('lesson:id,title,course_id')
|
||||
->latest('updated_at')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(fn ($row): array => [
|
||||
'lesson_id' => $row->lesson_id,
|
||||
'lesson_title' => $row->lesson?->title,
|
||||
'watched_percentage' => $row->watched_percentage,
|
||||
'completed' => $row->completed_at !== null,
|
||||
'last_active_at' => $row->updated_at->toIso8601String(),
|
||||
]);
|
||||
|
||||
$weakestConcepts = $student->conceptMastery()
|
||||
->with('concept:id,name')
|
||||
->orderBy('mastery_score')
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(fn ($row): array => [
|
||||
'concept' => $row->concept?->name,
|
||||
'mastery_score' => (float) $row->mastery_score,
|
||||
]);
|
||||
|
||||
$latestReport = $student->aiReports()
|
||||
->where('type', 'weakness_analysis')
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
return response()->json([
|
||||
'student' => [
|
||||
'id' => $student->id,
|
||||
'name' => $student->name,
|
||||
],
|
||||
'lessons_progress' => $progress,
|
||||
'weakest_concepts' => $weakestConcepts,
|
||||
'latest_ai_summary' => $latestReport?->summary,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Lesson;
|
||||
use App\Services\ProgressTrackingService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ProgressController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ProgressTrackingService $tracking) {}
|
||||
|
||||
public function heartbeat(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'lesson_id' => ['required', 'integer', 'exists:lessons,id'],
|
||||
'position_seconds' => ['required', 'integer', 'min:0'],
|
||||
]);
|
||||
|
||||
/** @var Lesson $lesson */
|
||||
$lesson = Lesson::query()->findOrFail($validated['lesson_id']);
|
||||
|
||||
$progress = $this->tracking->heartbeat(
|
||||
student: $request->user(),
|
||||
lesson: $lesson,
|
||||
positionSeconds: (int) $validated['position_seconds'],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'lesson_id' => $lesson->id,
|
||||
'watched_percentage' => $progress->watched_percentage,
|
||||
'last_position_seconds' => $progress->last_position_seconds,
|
||||
'completed' => $progress->completed_at !== null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Lesson;
|
||||
use App\Models\Quiz;
|
||||
use App\Services\ProgressTrackingService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class QuizAttemptController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ProgressTrackingService $tracking) {}
|
||||
|
||||
public function store(Request $request, Lesson $lesson, Quiz $quiz): JsonResponse
|
||||
{
|
||||
abort_unless($quiz->lesson_id === $lesson->id, 404);
|
||||
|
||||
$validated = $request->validate([
|
||||
'answers' => ['required', 'array', 'min:1'],
|
||||
'answers.*.question_id' => ['required', 'integer'],
|
||||
'answers.*.selected_option_id' => ['nullable', 'integer'],
|
||||
'answers.*.time_spent_seconds' => ['nullable', 'integer', 'min:0', 'max:7200'],
|
||||
]);
|
||||
|
||||
$result = $this->tracking->recordQuizAttempt(
|
||||
student: $request->user(),
|
||||
quiz: $quiz,
|
||||
submittedAnswers: $validated['answers'],
|
||||
);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\AnalyzeStudentPerformance;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class StudentAnalysisController extends Controller
|
||||
{
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User $student */
|
||||
$student = $request->user();
|
||||
|
||||
AnalyzeStudentPerformance::dispatch($student->id);
|
||||
|
||||
return response()->json(['queued' => true, 'message' => 'Analysis queued.'], 202);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ApiTokenAuth
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$token = $request->bearerToken();
|
||||
|
||||
if ($token === null || Str::length($token) < 32) {
|
||||
return response()->json(['message' => 'Unauthenticated.'], 401);
|
||||
}
|
||||
|
||||
/** @var User|null $user */
|
||||
$user = User::query()->where('api_token', $token)->first();
|
||||
|
||||
if ($user === null) {
|
||||
return response()->json(['message' => 'Unauthenticated.'], 401);
|
||||
}
|
||||
|
||||
$request->setUserResolver(fn () => $user);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Contracts\AiService;
|
||||
use App\Models\User;
|
||||
use App\Services\ProgressTrackingService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Throwable;
|
||||
|
||||
class AnalyzeStudentPerformance implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public function __construct(public int $studentId) {}
|
||||
|
||||
public function handle(ProgressTrackingService $tracking, AiService $ai): void
|
||||
{
|
||||
/** @var User|null $student */
|
||||
$student = User::query()->find($this->studentId);
|
||||
|
||||
if ($student === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = $ai->analyzeStudentPerformance($tracking->buildPerformanceSnapshot($student));
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$student->aiReports()->create([
|
||||
'type' => 'weakness_analysis',
|
||||
'payload' => $payload,
|
||||
'summary' => is_string($payload['summary'] ?? null) ? $payload['summary'] : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AiReport extends Model
|
||||
{
|
||||
protected $fillable = ['user_id', 'type', 'payload', 'summary'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'payload' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Answer extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'quiz_attempt_id',
|
||||
'question_id',
|
||||
'selected_option_id',
|
||||
'is_correct',
|
||||
'time_spent_seconds',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_correct' => 'boolean',
|
||||
];
|
||||
|
||||
public function attempt(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(QuizAttempt::class, 'quiz_attempt_id');
|
||||
}
|
||||
|
||||
public function question(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Question::class);
|
||||
}
|
||||
|
||||
public function selectedOption(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(QuestionOption::class, 'selected_option_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\ConceptFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Concept extends Model
|
||||
{
|
||||
/** @use HasFactory<ConceptFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['subject_id', 'name', 'remedial_lesson_id'];
|
||||
|
||||
public function subject(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Subject::class);
|
||||
}
|
||||
|
||||
public function remedialLesson(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Lesson::class, 'remedial_lesson_id');
|
||||
}
|
||||
|
||||
public function questions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Question::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ConceptMastery extends Model
|
||||
{
|
||||
protected $table = 'concept_mastery';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'concept_id',
|
||||
'correct_count',
|
||||
'wrong_count',
|
||||
'mastery_score',
|
||||
];
|
||||
|
||||
public function concept(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Concept::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\CourseFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Course extends Model
|
||||
{
|
||||
/** @use HasFactory<CourseFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['subject_id', 'grade', 'track', 'title', 'description'];
|
||||
|
||||
public function subject(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Subject::class);
|
||||
}
|
||||
|
||||
public function lessons(): HasMany
|
||||
{
|
||||
return $this->hasMany(Lesson::class)->orderBy('order_index');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\LessonFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Lesson extends Model
|
||||
{
|
||||
/** @use HasFactory<LessonFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['course_id', 'title', 'order_index', 'video_id', 'duration_seconds', 'transcript'];
|
||||
|
||||
public function course(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Course::class);
|
||||
}
|
||||
|
||||
public function concepts(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Concept::class);
|
||||
}
|
||||
|
||||
public function quizzes(): HasMany
|
||||
{
|
||||
return $this->hasMany(Quiz::class);
|
||||
}
|
||||
|
||||
public function progressFor(int $userId): ?LessonProgress
|
||||
{
|
||||
/** @var LessonProgress|null $progress */
|
||||
$progress = $this->hasOne(LessonProgress::class)
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
|
||||
return $progress;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class LessonProgress extends Model
|
||||
{
|
||||
protected $table = 'lesson_progress';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'lesson_id',
|
||||
'last_position_seconds',
|
||||
'watched_percentage',
|
||||
'completed_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function lesson(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Lesson::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\QuestionFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Question extends Model
|
||||
{
|
||||
/** @use HasFactory<QuestionFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['quiz_id', 'concept_id', 'body', 'explanation'];
|
||||
|
||||
public function quiz(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Quiz::class);
|
||||
}
|
||||
|
||||
public function concept(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Concept::class);
|
||||
}
|
||||
|
||||
public function options(): HasMany
|
||||
{
|
||||
return $this->hasMany(QuestionOption::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class QuestionOption extends Model
|
||||
{
|
||||
protected $fillable = ['question_id', 'body', 'is_correct'];
|
||||
|
||||
protected $casts = [
|
||||
'is_correct' => 'boolean',
|
||||
];
|
||||
|
||||
public function question(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Question::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\QuizFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Quiz extends Model
|
||||
{
|
||||
/** @use HasFactory<QuizFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['lesson_id', 'title', 'trigger_seconds'];
|
||||
|
||||
public function lesson(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Lesson::class);
|
||||
}
|
||||
|
||||
public function questions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Question::class);
|
||||
}
|
||||
|
||||
public function attempts(): HasMany
|
||||
{
|
||||
return $this->hasMany(QuizAttempt::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class QuizAttempt extends Model
|
||||
{
|
||||
protected $fillable = ['user_id', 'quiz_id', 'score'];
|
||||
|
||||
public function quiz(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Quiz::class);
|
||||
}
|
||||
|
||||
public function answers(): HasMany
|
||||
{
|
||||
return $this->hasMany(Answer::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\SubjectFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Subject extends Model
|
||||
{
|
||||
/** @use HasFactory<SubjectFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['name', 'slug'];
|
||||
|
||||
public function courses(): HasMany
|
||||
{
|
||||
return $this->hasMany(Course::class);
|
||||
}
|
||||
|
||||
public function concepts(): HasMany
|
||||
{
|
||||
return $this->hasMany(Concept::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
#[Fillable(['name', 'email', 'password', 'role', 'api_token', 'grade', 'track'])]
|
||||
#[Hidden(['password', 'remember_token', 'api_token'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
public function isGuardian(): bool
|
||||
{
|
||||
return $this->role === 'guardian';
|
||||
}
|
||||
|
||||
public function students(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'guardian_student', 'guardian_id', 'student_id');
|
||||
}
|
||||
|
||||
public function guardians(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'guardian_student', 'student_id', 'guardian_id');
|
||||
}
|
||||
|
||||
public function lessonProgress(): HasMany
|
||||
{
|
||||
return $this->hasMany(LessonProgress::class);
|
||||
}
|
||||
|
||||
public function conceptMastery(): HasMany
|
||||
{
|
||||
return $this->hasMany(ConceptMastery::class);
|
||||
}
|
||||
|
||||
public function aiReports(): HasMany
|
||||
{
|
||||
return $this->hasMany(AiReport::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Contracts\AiService;
|
||||
use App\Services\GeminiService;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->bind(AiService::class, GeminiService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -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