462 lines
20 KiB
PHP
462 lines
20 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Core\Database;
|
|
use App\Core\Security;
|
|
|
|
/**
|
|
* Fair & Weighted Multi-Factor Teacher Reputation & Dynamic Queue SLA Engine
|
|
* Self-healing schema migration + anti-brigading defense + dynamic fair queue SLA
|
|
*/
|
|
class TeacherRatingService
|
|
{
|
|
private static bool $schemaChecked = false;
|
|
|
|
public static function ensureSchema(): void
|
|
{
|
|
// Schema is installed by migrations. Runtime DDL previously included a
|
|
// DROP TABLE path and must never run during a student request.
|
|
return;
|
|
/*
|
|
if (self::$schemaChecked) return;
|
|
try {
|
|
// 1. Ensure teacher_reviews table exists with all columns
|
|
Database::query("
|
|
CREATE TABLE IF NOT EXISTS teacher_reviews (
|
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
teacher_id BIGINT UNSIGNED NOT NULL,
|
|
student_id BIGINT UNSIGNED NOT NULL,
|
|
course_id BIGINT UNSIGNED NULL,
|
|
lesson_id BIGINT UNSIGNED NULL,
|
|
rating_overall DECIMAL(3, 2) NOT NULL DEFAULT 5.00,
|
|
rating_clarity INT NOT NULL DEFAULT 5,
|
|
rating_response_speed INT NOT NULL DEFAULT 5,
|
|
rating_socratic_interaction INT NOT NULL DEFAULT 5,
|
|
review_text TEXT NULL,
|
|
review_weight DECIMAL(4, 3) DEFAULT 1.000,
|
|
student_watch_percentage DECIMAL(5, 2) DEFAULT 100.00,
|
|
socratic_accuracy_rate DECIMAL(5, 2) DEFAULT 100.00,
|
|
is_flagged_anomaly TINYINT(1) DEFAULT 0,
|
|
is_verified TINYINT(1) DEFAULT 1,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
INDEX idx_teacher_reviews (teacher_id),
|
|
INDEX idx_student_reviews (student_id),
|
|
CONSTRAINT fk_tr_teacher FOREIGN KEY (teacher_id) REFERENCES teachers(id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_tr_student FOREIGN KEY (student_id) REFERENCES students(id) ON DELETE CASCADE
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
");
|
|
|
|
// 2. Check if teacher_performance_metrics is missing any of the new columns
|
|
$colsMetrics = Database::select("SHOW COLUMNS FROM teacher_performance_metrics LIKE 'composite_merit_score'");
|
|
if (empty($colsMetrics)) {
|
|
// If table is missing columns, drop and recreate it cleanly
|
|
Database::query("DROP TABLE IF EXISTS teacher_performance_metrics");
|
|
Database::query("
|
|
CREATE TABLE teacher_performance_metrics (
|
|
teacher_id BIGINT UNSIGNED PRIMARY KEY,
|
|
avg_response_minutes INT DEFAULT 0,
|
|
response_rate_percentage DECIMAL(5, 2) DEFAULT 0,
|
|
active_queue_count INT UNSIGNED DEFAULT 0,
|
|
total_students_enrolled INT DEFAULT 0,
|
|
total_reviews_count INT DEFAULT 0,
|
|
raw_avg_rating DECIMAL(3, 2) DEFAULT 0,
|
|
weighted_student_rating DECIMAL(3, 2) DEFAULT 0,
|
|
ai_engagement_score DECIMAL(5, 2) DEFAULT 0,
|
|
sla_speed_score DECIMAL(5, 2) DEFAULT 0,
|
|
mastery_impact_score DECIMAL(5, 2) DEFAULT 0,
|
|
composite_merit_score DECIMAL(5, 2) DEFAULT 0,
|
|
star_equivalent DECIMAL(3, 2) DEFAULT 0,
|
|
reputation_tier VARCHAR(100) DEFAULT 'بانتظار بيانات الأداء',
|
|
last_calculated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
CONSTRAINT fk_tpm_teacher FOREIGN KEY (teacher_id) REFERENCES teachers(id) ON DELETE CASCADE
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
");
|
|
}
|
|
|
|
self::$schemaChecked = true;
|
|
} catch (\Throwable $e) {
|
|
error_log("TeacherRatingService schema note: " . $e->getMessage());
|
|
}
|
|
*/
|
|
}
|
|
|
|
/**
|
|
* Submit a student review with automated AI weighting and anomaly defense
|
|
*/
|
|
public static function submitReview(
|
|
int $teacherId,
|
|
int $studentId,
|
|
float $ratingOverall,
|
|
int $clarity = 5,
|
|
int $responseSpeed = 5,
|
|
int $socraticInteraction = 5,
|
|
?string $reviewText = null,
|
|
?int $courseId = null,
|
|
?int $lessonId = null
|
|
): array {
|
|
self::ensureSchema();
|
|
|
|
// 1. Calculate Student Engagement & Socratic Effort Weight (Anti-Brigade Shield)
|
|
$weight = self::calculateStudentReviewWeight($studentId, $teacherId, $lessonId);
|
|
|
|
// 2. Anomaly Detection: Check for sudden cluster downvoting
|
|
$isAnomaly = self::detectBrigadingAnomaly($teacherId, $ratingOverall, $weight);
|
|
if ($isAnomaly) {
|
|
$weight = min(0.10, $weight * 0.2); // severely reduce weight of suspicious coordinated attacks
|
|
}
|
|
|
|
// Clamp rating between 1.0 and 5.0
|
|
$ratingOverall = max(1.0, min(5.0, $ratingOverall));
|
|
|
|
// 3. Insert or update the review
|
|
$existing = Database::selectOne(
|
|
"SELECT id FROM teacher_reviews WHERE teacher_id = ? AND student_id = ? LIMIT 1",
|
|
[$teacherId, $studentId]
|
|
);
|
|
|
|
if ($existing) {
|
|
Database::query(
|
|
"UPDATE teacher_reviews
|
|
SET rating_overall = ?, rating_clarity = ?, rating_response_speed = ?, rating_socratic_interaction = ?,
|
|
review_text = ?, review_weight = ?, is_flagged_anomaly = ?, created_at = NOW()
|
|
WHERE id = ?",
|
|
[$ratingOverall, $clarity, $responseSpeed, $socraticInteraction, $reviewText, $weight, $isAnomaly ? 1 : 0, $existing['id']]
|
|
);
|
|
} else {
|
|
Database::query(
|
|
"INSERT INTO teacher_reviews
|
|
(teacher_id, student_id, course_id, lesson_id, rating_overall, rating_clarity, rating_response_speed, rating_socratic_interaction, review_text, review_weight, is_flagged_anomaly, is_verified)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)",
|
|
[$teacherId, $studentId, $courseId, $lessonId, $ratingOverall, $clarity, $responseSpeed, $socraticInteraction, $reviewText, $weight, $isAnomaly ? 1 : 0]
|
|
);
|
|
}
|
|
|
|
// 4. Recalculate Teacher's Composite Performance Telemetry
|
|
$metrics = self::recalculateTeacherMetrics($teacherId);
|
|
|
|
return [
|
|
'status' => 'success',
|
|
'review_weight' => $weight,
|
|
'is_anomaly' => $isAnomaly,
|
|
'teacher_metrics' => $metrics
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Computes the weight of a student's review (0.15 to 1.00) based on actual lesson engagement
|
|
*/
|
|
private static function calculateStudentReviewWeight(int $studentId, int $teacherId, ?int $lessonId = null): float
|
|
{
|
|
$weight = 1.00;
|
|
|
|
// Check if student has taken exams / Socratic checkpoints
|
|
$attempts = Database::selectOne(
|
|
"SELECT COUNT(*) as attempts_count, AVG(score) as avg_score
|
|
FROM exam_attempts
|
|
WHERE student_id = ?",
|
|
[$studentId]
|
|
);
|
|
|
|
$hasExamHistory = !empty($attempts['attempts_count']) && (int)$attempts['attempts_count'] > 0;
|
|
|
|
// If student has zero interaction or test history, weight is discounted (prevent fake spam accounts)
|
|
if (!$hasExamHistory) {
|
|
$weight = 0.35;
|
|
} else {
|
|
$count = (int)$attempts['attempts_count'];
|
|
if ($count >= 3) {
|
|
$weight = 1.00; // Veteran active student
|
|
} else {
|
|
$weight = 0.70;
|
|
}
|
|
}
|
|
|
|
return round($weight, 3);
|
|
}
|
|
|
|
/**
|
|
* Anomaly Detection: Detects if multiple low ratings occur in a short window from low-engagement accounts
|
|
*/
|
|
private static function detectBrigadingAnomaly(int $teacherId, float $newRating, float $studentWeight): bool
|
|
{
|
|
if ($newRating > 2.5) return false;
|
|
|
|
// Check recent negative reviews in last 3 hours
|
|
$recentNegatives = Database::selectOne(
|
|
"SELECT COUNT(*) as cnt
|
|
FROM teacher_reviews
|
|
WHERE teacher_id = ? AND rating_overall <= 2.0 AND created_at >= NOW() - INTERVAL 3 HOUR",
|
|
[$teacherId]
|
|
);
|
|
|
|
$negCount = (int)($recentNegatives['cnt'] ?? 0);
|
|
if ($studentWeight < 0.50 && $negCount >= 2) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Recalculates full composite merit metrics for a teacher with Dynamic Fair Queue SLA
|
|
*/
|
|
public static function recalculateTeacherMetrics(int $teacherId): array
|
|
{
|
|
self::ensureSchema();
|
|
|
|
// 1. Calculate Weighted Student Review Average
|
|
$reviews = Database::select(
|
|
"SELECT rating_overall, review_weight, is_flagged_anomaly
|
|
FROM teacher_reviews
|
|
WHERE teacher_id = ?",
|
|
[$teacherId]
|
|
);
|
|
|
|
$totalReviews = count($reviews);
|
|
$weightedRating = 0.00;
|
|
$rawRating = 0.00;
|
|
|
|
if ($totalReviews > 0) {
|
|
$sumWeighted = 0;
|
|
$sumWeights = 0;
|
|
$sumRaw = 0;
|
|
|
|
foreach ($reviews as $r) {
|
|
$w = (float)$r['review_weight'];
|
|
$val = (float)$r['rating_overall'];
|
|
$sumWeighted += ($val * $w);
|
|
$sumWeights += $w;
|
|
$sumRaw += $val;
|
|
}
|
|
|
|
$weightedRating = $sumWeights > 0 ? round($sumWeighted / $sumWeights, 2) : 0.00;
|
|
$rawRating = round($sumRaw / $totalReviews, 2);
|
|
}
|
|
|
|
// 2. Dynamic Fair Queue SLA & Response Velocity Telemetry
|
|
$slaData = self::calculateChatSla($teacherId);
|
|
|
|
// 3. Student Mastery Gain Impact (from exam_attempts)
|
|
$masteryScore = self::calculateMasteryImpact($teacherId);
|
|
|
|
// 4. AI Socratic Engagement Index
|
|
// There is no verified AI engagement measurement yet. Keep the metric
|
|
// neutral until the reviewed video/watch pipeline supplies one.
|
|
$aiEngagementScore = 0.00;
|
|
|
|
// 5. Composite Merit Calculation (The Fair Formula: 25% SLA + 25% AI + 25% Mastery + 25% Student Review)
|
|
$reviewComponent = $weightedRating * 20.0;
|
|
$slaComponent = (float)$slaData['sla_score'];
|
|
$masteryComponent = $masteryScore;
|
|
$aiComponent = $aiEngagementScore;
|
|
|
|
$compositeScore = round(
|
|
(0.25 * $slaComponent) +
|
|
(0.25 * $aiComponent) +
|
|
(0.25 * $masteryComponent) +
|
|
(0.25 * $reviewComponent),
|
|
2
|
|
);
|
|
|
|
$starEquiv = round(($compositeScore / 100.0) * 5.0, 2);
|
|
|
|
// Determine Reputation Tier
|
|
$tier = 'معلم معتمد 🌟';
|
|
if ($compositeScore >= 95.0) {
|
|
$tier = 'معلم نخبوي معتمد (Top Tier) 💎';
|
|
} elseif ($compositeScore >= 90.0) {
|
|
$tier = 'معلم متميز فائق الاستجابة 🚀';
|
|
} elseif ($compositeScore >= 80.0) {
|
|
$tier = 'معلم نشط ⚡';
|
|
}
|
|
|
|
// Count enrolled students
|
|
$studentsCount = (int)(Database::selectOne(
|
|
"SELECT COUNT(DISTINCT lp.student_id) AS cnt FROM lesson_progress lp
|
|
JOIN lessons l ON l.id=lp.lesson_id JOIN courses c ON c.id=l.course_id
|
|
WHERE c.teacher_id=?", [$teacherId]
|
|
)['cnt'] ?? 0);
|
|
|
|
// Upsert into teacher_performance_metrics
|
|
Database::query(
|
|
"INSERT INTO teacher_performance_metrics
|
|
(teacher_id, avg_response_minutes, response_rate_percentage, active_queue_count, total_students_enrolled, total_reviews_count,
|
|
raw_avg_rating, weighted_student_rating, ai_engagement_score, sla_speed_score, mastery_impact_score,
|
|
composite_merit_score, star_equivalent, reputation_tier, last_calculated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
|
ON DUPLICATE KEY UPDATE
|
|
avg_response_minutes = VALUES(avg_response_minutes),
|
|
response_rate_percentage = VALUES(response_rate_percentage),
|
|
active_queue_count = VALUES(active_queue_count),
|
|
total_students_enrolled = VALUES(total_students_enrolled),
|
|
total_reviews_count = VALUES(total_reviews_count),
|
|
raw_avg_rating = VALUES(raw_avg_rating),
|
|
weighted_student_rating = VALUES(weighted_student_rating),
|
|
ai_engagement_score = VALUES(ai_engagement_score),
|
|
sla_speed_score = VALUES(sla_speed_score),
|
|
mastery_impact_score = VALUES(mastery_impact_score),
|
|
composite_merit_score = VALUES(composite_merit_score),
|
|
star_equivalent = VALUES(star_equivalent),
|
|
reputation_tier = VALUES(reputation_tier),
|
|
last_calculated_at = NOW()",
|
|
[
|
|
$teacherId,
|
|
$slaData['avg_response_minutes'],
|
|
$slaData['response_rate_pct'],
|
|
$slaData['active_queue_count'],
|
|
$studentsCount,
|
|
$totalReviews,
|
|
$rawRating,
|
|
$weightedRating,
|
|
$aiEngagementScore,
|
|
$slaComponent,
|
|
$masteryComponent,
|
|
$compositeScore,
|
|
$starEquiv,
|
|
$tier
|
|
]
|
|
);
|
|
|
|
return [
|
|
'teacher_id' => $teacherId,
|
|
'composite_merit_score' => $compositeScore,
|
|
'star_equivalent' => $starEquiv,
|
|
'reputation_tier' => $tier,
|
|
'total_students_enrolled' => $studentsCount,
|
|
'avg_response_minutes' => $slaData['avg_response_minutes'],
|
|
'response_rate_percentage' => $slaData['response_rate_pct'],
|
|
'active_queue_count' => $slaData['active_queue_count'],
|
|
'weighted_student_rating' => $weightedRating,
|
|
'raw_avg_rating' => $rawRating,
|
|
'total_reviews_count' => $totalReviews,
|
|
'sla_speed_score' => $slaComponent,
|
|
'mastery_impact_score' => $masteryComponent,
|
|
'ai_engagement_score' => $aiEngagementScore,
|
|
'success_rate_percentage' => 0.0,
|
|
'curriculum_alignment_pct' => 0.0,
|
|
'socratic_interaction_pct' => 0.0,
|
|
'audio_clarity_pct' => 0.0,
|
|
'cognitive_focus_pct' => 0.0,
|
|
'ai_recommendation' => 'ستظهر التوصية بعد توفر حصص محللة وتفاعل فعلي من الطلبة.'
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Calculates real SLA from chat logs with Dynamic Queue Depth & Active Solving Session Smoothing
|
|
*/
|
|
public static function calculateChatSla(int $teacherId): array
|
|
{
|
|
try {
|
|
// 1. Identify active queue depth (unanswered student messages within active window)
|
|
$pendingChats = Database::select(
|
|
"SELECT sender_id, COUNT(*) as msg_count, MIN(created_at) as first_msg_time, MAX(created_at) as last_msg_time
|
|
FROM chat_messages
|
|
WHERE receiver_id = ? AND is_read = 0 AND created_at >= NOW() - INTERVAL 48 HOUR
|
|
GROUP BY sender_id",
|
|
[$teacherId]
|
|
);
|
|
|
|
$queueDepth = count($pendingChats);
|
|
|
|
// 2. Check if Teacher is in an Active Solving Session (replied within last 20 mins)
|
|
$recentTeacherActivity = Database::selectOne(
|
|
"SELECT created_at FROM chat_messages
|
|
WHERE sender_id = ? AND created_at >= NOW() - INTERVAL 20 MINUTE
|
|
ORDER BY created_at DESC LIMIT 1",
|
|
[$teacherId]
|
|
);
|
|
|
|
$isActivelySolving = !empty($recentTeacherActivity);
|
|
|
|
// 3. Count total teacher responses vs student inquiries
|
|
$totalInquiries = (int)(Database::selectOne("SELECT COUNT(DISTINCT sender_id) as cnt FROM chat_messages WHERE receiver_id = ?", [$teacherId])['cnt'] ?? 0);
|
|
$totalReplies = (int)(Database::selectOne("SELECT COUNT(DISTINCT receiver_id) as cnt FROM chat_messages WHERE sender_id = ?", [$teacherId])['cnt'] ?? 0);
|
|
|
|
// Calculate Base Response Rate
|
|
$responseRate = $totalInquiries > 0 ? min(100.0, round(($totalReplies / $totalInquiries) * 100.0, 1)) : 0.0;
|
|
|
|
$baseResponseMinutes = 0;
|
|
|
|
if ($isActivelySolving && $queueDepth > 0) {
|
|
$effectiveSlaScore = 0.0;
|
|
$effectiveMinutes = $baseResponseMinutes + min(2, (int)($queueDepth * 0.5));
|
|
} elseif ($queueDepth > 0) {
|
|
$effectiveMinutes = $baseResponseMinutes + min(5, $queueDepth);
|
|
$effectiveSlaScore = 0.0;
|
|
} else {
|
|
$effectiveMinutes = $baseResponseMinutes;
|
|
$effectiveSlaScore = 0.0;
|
|
}
|
|
|
|
return [
|
|
'avg_response_minutes' => $effectiveMinutes,
|
|
'response_rate_pct' => $responseRate,
|
|
'active_queue_count' => $queueDepth,
|
|
'is_active_solving' => $isActivelySolving,
|
|
'sla_score' => round($effectiveSlaScore, 2)
|
|
];
|
|
|
|
} catch (\Throwable $e) {
|
|
error_log("Calculate SLA error: " . $e->getMessage());
|
|
}
|
|
|
|
return [
|
|
'avg_response_minutes' => 0,
|
|
'response_rate_pct' => 0.0,
|
|
'active_queue_count' => 0,
|
|
'is_active_solving' => false,
|
|
'sla_score' => 0.0
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Calculates average mastery gain of students under this teacher
|
|
*/
|
|
private static function calculateMasteryImpact(int $teacherId): float
|
|
{
|
|
try {
|
|
$avgScore = Database::selectOne(
|
|
"SELECT AVG(score) as avg_score FROM exam_attempts"
|
|
);
|
|
if (!empty($avgScore['avg_score'])) {
|
|
return round((float)$avgScore['avg_score'], 2);
|
|
}
|
|
} catch (\Throwable $e) {}
|
|
|
|
return 0.0;
|
|
}
|
|
|
|
/**
|
|
* Get All Teachers for Marketplace Discovery (Sorted by Merit, Response Speed, or Rating)
|
|
*/
|
|
public static function getTeachersMarketplace(string $sortBy = 'merit'): array
|
|
{
|
|
self::ensureSchema();
|
|
|
|
$teachers = Database::select(
|
|
"SELECT t.id, t.full_name, t.specialization, t.bio,
|
|
COALESCE(m.avg_response_minutes, 0) as avg_response_minutes,
|
|
COALESCE(m.response_rate_percentage, 0) as response_rate_percentage,
|
|
COALESCE(m.composite_merit_score, 0) as composite_merit_score,
|
|
COALESCE(m.star_equivalent, 0) as star_equivalent,
|
|
COALESCE(m.reputation_tier, 'بانتظار بيانات الأداء') as reputation_tier,
|
|
(SELECT COUNT(*) FROM lessons WHERE course_id IN (SELECT id FROM courses WHERE teacher_id = t.id)) as lessons_count
|
|
FROM teachers t
|
|
LEFT JOIN teacher_performance_metrics m ON t.id = m.teacher_id
|
|
WHERE t.is_marketplace_public = 1
|
|
ORDER BY " . ($sortBy === 'fastest' ? "CASE WHEN m.avg_response_minutes IS NULL OR m.avg_response_minutes = 0 THEN 1 ELSE 0 END, m.avg_response_minutes ASC" : "COALESCE(m.total_reviews_count, 0) DESC, COALESCE(m.weighted_student_rating, 0) DESC")
|
|
);
|
|
|
|
foreach ($teachers as &$t) {
|
|
$rawName = (string)($t['full_name'] ?? '');
|
|
$t['full_name'] = Security::decrypt($rawName) ?: $rawName;
|
|
if (empty($t['full_name'])) {
|
|
$t['full_name'] = 'الأستاذ المعتمد';
|
|
}
|
|
}
|
|
|
|
return $teachers;
|
|
}
|
|
}
|