Files
saqel/backend/app/Services/VideoReviewService.php
T

160 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services;
use App\Core\Database;
final class VideoReviewService
{
public static function recordHumanReview(string $jobUuid, int $reviewerId, string $recommendation, string $decision, array $report): array
{
if (!in_array($recommendation, ['reject','revise','manual_review','ready_for_human_review'], true) || !in_array($decision, ['approved','rejected'], true)) {
return ['http_status'=>400,'status'=>'error','message'=>'قرار المراجعة غير صالح.'];
}
if (empty($report['bindings']) || empty($report['coverage'])) {
return ['http_status'=>422,'status'=>'incomplete_report','message'=>'التقرير لا يحتوي bindings وتغطية الأدلة المطلوبة.'];
}
$pdo=Database::getConnection(); $pdo->beginTransaction();
try {
$job=Database::selectOne('SELECT j.id,j.status,e.id AS evidence_id FROM video_review_jobs j LEFT JOIN video_review_evidence e ON e.video_review_job_id=j.id WHERE j.uuid=? FOR UPDATE',[$jobUuid]);
if(!$job) return self::finish($pdo,['http_status'=>404,'status'=>'error','message'=>'مهمة الفحص غير موجودة.']);
if(($job['status'] ?? '') !== 'ready_for_human_review') return self::finish($pdo,['http_status'=>409,'status'=>'job_not_reviewable','message'=>'حالة المهمة لا تسمح بقرار جديد.']);
if (empty($job['evidence_id'])) return self::finish($pdo,['http_status'=>422,'status'=>'missing_evidence','message'=>'لا يمكن اعتماد الفحص من دون أدلة فيديو محفوظة.']);
$approved=$decision==='approved' && $recommendation==='ready_for_human_review';
Database::query("INSERT INTO video_review_reports (video_review_job_id,recommendation,report_json,reviewer_id,human_decision,reviewed_at) VALUES (?,?,?,?,?,NOW()) ON DUPLICATE KEY UPDATE recommendation=VALUES(recommendation),report_json=VALUES(report_json),reviewer_id=VALUES(reviewer_id),human_decision=VALUES(human_decision),reviewed_at=NOW()",[$job['id'],$recommendation,json_encode($report,JSON_UNESCAPED_UNICODE),$reviewerId,$decision]);
Database::query('UPDATE video_review_jobs SET status=?, completed_at=NOW() WHERE id=?',[$approved?'approved':'rejected',$job['id']]);
return self::finish($pdo,['http_status'=>200,'status'=>$approved?'approved':'rejected']);
} catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;}
}
/** Attach reviewed, version-bound evidence before human review. */
public static function submitEvidence(string $jobUuid, int $reviewerId, string $transcriptAssetUuid, string $visualAssetUuid, array $coverage): array
{
if (!self::isUuid($jobUuid) || !self::isUuid($transcriptAssetUuid) || !self::isUuid($visualAssetUuid)) {
return ['http_status'=>400,'status'=>'error','message'=>'هويات أدلة الفحص غير صالحة.'];
}
if (empty($coverage['segments']) || !is_array($coverage['segments'])) {
return ['http_status'=>422,'status'=>'incomplete_evidence','message'=>'يلزم سجل تغطية زمني للفيديو.'];
}
$pdo=Database::getConnection(); $pdo->beginTransaction();
try {
$job=Database::selectOne('SELECT id,video_sha256,status FROM video_review_jobs WHERE uuid=? FOR UPDATE',[$jobUuid]);
if (!$job) return self::finish($pdo,['http_status'=>404,'status'=>'error','message'=>'مهمة الفحص غير موجودة.']);
if (!in_array($job['status'],['queued','collecting_evidence','needs_evidence'],true)) return self::finish($pdo,['http_status'=>409,'status'=>'job_not_collecting','message'=>'لا تقبل المهمة أدلة جديدة في حالتها الحالية.']);
$transcript=Database::selectOne("SELECT id,sha256,asset_type,review_status,source_reference FROM content_assets WHERE uuid=? FOR UPDATE",[$transcriptAssetUuid]);
$visual=Database::selectOne("SELECT id,sha256,asset_type,review_status,source_reference FROM content_assets WHERE uuid=? FOR UPDATE",[$visualAssetUuid]);
if (!$transcript || !$visual || $transcript['asset_type'] !== 'transcript' || $transcript['review_status'] !== 'approved' || $visual['review_status'] !== 'approved') {
return self::finish($pdo,['http_status'=>422,'status'=>'untrusted_evidence','message'=>'يلزم تفريغ زمني ودليل بصري معتمدان.']);
}
$segments=$coverage['segments']; $lastEnd=0;
foreach ($segments as $segment) {
if (!is_array($segment) || !isset($segment['start_seconds'],$segment['end_seconds'],$segment['transcript_ref'],$segment['visual_ref']) || !is_numeric($segment['start_seconds']) || !is_numeric($segment['end_seconds']) || (float)$segment['start_seconds'] < $lastEnd || (float)$segment['end_seconds'] <= (float)$segment['start_seconds']) {
return self::finish($pdo,['http_status'=>422,'status'=>'invalid_coverage','message'=>'تغطية الفيديو يجب أن تكون متصلة ومربوطة بالتفريغ والدليل البصري.']);
}
$lastEnd=(float)$segment['end_seconds'];
}
Database::query('INSERT INTO video_review_evidence (video_review_job_id,transcript_asset_id,visual_evidence_asset_id,coverage_json,submitted_by) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE transcript_asset_id=VALUES(transcript_asset_id),visual_evidence_asset_id=VALUES(visual_evidence_asset_id),coverage_json=VALUES(coverage_json),submitted_by=VALUES(submitted_by),created_at=NOW()',[$job['id'],$transcript['id'],$visual['id'],json_encode($coverage,JSON_UNESCAPED_UNICODE),$reviewerId]);
Database::query("UPDATE video_review_jobs SET status='ready_for_human_review' WHERE id=?",[$job['id']]);
return self::finish($pdo,['http_status'=>200,'status'=>'ready_for_human_review']);
} catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;}
}
public static function publishApproved(string $jobUuid, ?string $expectedCurrentVersionUuid): array
{
$pdo = Database::getConnection();
$pdo->beginTransaction();
try {
$job = Database::selectOne(
"SELECT j.id, j.status, j.video_version_id, vv.uuid AS version_uuid,
vv.source_lesson_id, ts.id AS submission_id,
currentv.uuid AS current_version_uuid
FROM video_review_jobs j
JOIN video_versions vv ON vv.id = j.video_version_id
JOIN teacher_submissions ts ON ts.id = vv.teacher_submission_id
LEFT JOIN video_versions currentv ON currentv.id = ts.current_published_video_version_id
WHERE j.uuid = ? FOR UPDATE",
[$jobUuid]
);
if (!$job) return self::finish($pdo, ['status' => 'error', 'http_status' => 404, 'message' => 'مهمة الفحص غير موجودة.']);
$report = Database::selectOne('SELECT human_decision FROM video_review_reports WHERE video_review_job_id = ? FOR UPDATE', [$job['id']]);
if (($job['status'] ?? '') !== 'approved' || ($report['human_decision'] ?? '') !== 'approved') {
return self::finish($pdo, ['status' => 'review_not_approved', 'http_status' => 409, 'message' => 'لا يمكن النشر قبل تقرير مكتمل وموافقة مراجع بشري.']);
}
if ($expectedCurrentVersionUuid !== null && !hash_equals($expectedCurrentVersionUuid, (string)($job['current_version_uuid'] ?? ''))) {
return self::finish($pdo, ['status' => 'current_version_conflict', 'http_status' => 409, 'message' => 'تغيرت النسخة المنشورة منذ فتح المراجعة.']);
}
if (empty($job['source_lesson_id'])) return self::finish($pdo, ['status' => 'storage_not_ready', 'http_status' => 409, 'message' => 'الفيديو غير مرتبط بتخزين جاهز.']);
Database::query("UPDATE video_versions SET status = 'superseded' WHERE teacher_submission_id = ? AND status = 'published'", [$job['submission_id']]);
Database::query("UPDATE video_versions SET status = 'published', published_at = NOW() WHERE id = ?", [$job['video_version_id']]);
Database::query("UPDATE teacher_submissions SET status = 'published', current_published_video_version_id = ? WHERE id = ?", [$job['video_version_id'], $job['submission_id']]);
$pdo->commit();
return ['status' => 'published', 'http_status' => 200, 'video_version_id' => $job['version_uuid']];
} catch (\Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
throw $e;
}
}
public static function queueForVersion(int $versionId): array
{
$existing = Database::selectOne(
"SELECT uuid, status FROM video_review_jobs
WHERE video_version_id = ? AND status NOT IN ('failed', 'superseded')
ORDER BY id DESC LIMIT 1",
[$versionId]
);
if ($existing) return ['job_id' => $existing['uuid'], 'status' => $existing['status'], 'reused' => true];
$binding = Database::selectOne(
"SELECT vv.content_sha256, ts.curriculum_lesson_id, cl.uuid AS lesson_uuid,
cl.curriculum_version, a.uuid AS asset_uuid, a.sha256 AS markdown_sha256
FROM video_versions vv
JOIN teacher_submissions ts ON ts.id = vv.teacher_submission_id
JOIN curriculum_lessons cl ON cl.id = ts.curriculum_lesson_id
LEFT JOIN publication_bundles pb ON pb.curriculum_lesson_id = cl.id AND pb.status = 'published'
LEFT JOIN publication_bundle_assets pba ON pba.publication_bundle_id = pb.id AND pba.role = 'primary_lesson'
LEFT JOIN content_assets a ON a.id = pba.content_asset_id AND a.review_status = 'approved'
WHERE vv.id = ? LIMIT 1",
[$versionId]
);
if (!$binding) throw new \RuntimeException('نسخة الفيديو غير مرتبطة بدرس منهجي.');
$missing = ['timestamped_transcript', 'visual_evidence', 'coverage_manifest'];
if (empty($binding['asset_uuid'])) $missing[] = 'approved_markdown_asset';
$jobUuid = self::uuid();
$manifest = [
'curriculum_lesson_id' => $binding['lesson_uuid'],
'curriculum_version' => $binding['curriculum_version'],
'markdown_asset_id' => $binding['asset_uuid'] ?: null,
'missing_inputs' => $missing,
];
Database::insert(
"INSERT INTO video_review_jobs
(uuid, video_version_id, curriculum_lesson_id, prompt_version, schema_version, video_sha256, markdown_sha256, status, input_manifest_json)
VALUES (?, ?, ?, 'video-review-v1', 'video-review-schema-v1', ?, ?, 'needs_evidence', ?)",
[$jobUuid, $versionId, $binding['curriculum_lesson_id'], $binding['content_sha256'] ?: str_repeat('0', 64), $binding['markdown_sha256'] ?: str_repeat('0', 64), json_encode($manifest, JSON_UNESCAPED_UNICODE)]
);
return ['job_id' => $jobUuid, 'status' => 'needs_evidence', 'missing_inputs' => $missing];
}
private static function isUuid(string $value): bool
{
return (bool)preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $value);
}
private static function uuid(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
}
private static function finish(\PDO $pdo, array $result): array
{
$pdo->commit();
return $result;
}
}