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

211 lines
17 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,j.video_sha256,j.markdown_sha256,vv.uuid AS video_version_uuid,cl.uuid AS curriculum_lesson_uuid,e.id AS evidence_id,e.coverage_json FROM video_review_jobs j JOIN video_versions vv ON vv.id=j.video_version_id JOIN curriculum_lessons cl ON cl.id=j.curriculum_lesson_id 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'=>'لا يمكن اعتماد الفحص من دون أدلة فيديو محفوظة.']);
$bindings = $report['bindings'];
$coverageReport = $report['coverage'];
$savedCoverage = json_decode((string)$job['coverage_json'], true);
if (!is_array($bindings) || !is_array($coverageReport) || !is_array($savedCoverage) ||
!hash_equals((string)$job['video_version_uuid'], (string)($bindings['video_version_id'] ?? '')) ||
!hash_equals((string)$job['curriculum_lesson_uuid'], (string)($bindings['curriculum_lesson_id'] ?? '')) ||
!hash_equals((string)$job['video_sha256'], (string)($bindings['video_sha256'] ?? '')) ||
!hash_equals((string)$job['markdown_sha256'], (string)($bindings['markdown_sha256'] ?? '')) ||
($coverageReport['truncated'] ?? true) !== false || !empty($coverageReport['unprocessed_intervals'])) {
return self::finish($pdo,['http_status'=>422,'status'=>'stale_or_incomplete_report','message'=>'ارتباطات التقرير أو تغطيته لا تطابق النسخة والمصدر.']);
}
if ($decision === 'approved' && !VideoReviewReportValidator::approvable($report)) {
return self::finish($pdo,['http_status'=>422,'status'=>'blocking_findings','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'=>'هويات أدلة الفحص غير صالحة.'];
}
$pdo=Database::getConnection(); $pdo->beginTransaction();
try {
$job=Database::selectOne('SELECT j.id,j.video_sha256,j.markdown_sha256,j.status,j.video_version_id,l.duration_seconds
FROM video_review_jobs j JOIN video_versions vv ON vv.id=j.video_version_id
JOIN lessons l ON l.id=vv.source_lesson_id WHERE j.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['asset_type'] !== 'other' || $visual['review_status'] !== 'approved' ||
!hash_equals('video:'.(string)$job['video_sha256'], (string)$transcript['source_reference']) ||
!hash_equals('video:'.(string)$job['video_sha256'], (string)$visual['source_reference'])) {
return self::finish($pdo,['http_status'=>422,'status'=>'untrusted_evidence','message'=>'يلزم تفريغ زمني ودليل بصري معتمدان.']);
}
if (!VideoCoverageValidator::valid($coverage, (int)$job['duration_seconds'], (string)$job['video_sha256']) ||
!hash_equals((string)$job['markdown_sha256'], (string)($coverage['markdown_sha256'] ?? ''))) {
return self::finish($pdo,['http_status'=>422,'status'=>'invalid_coverage','message'=>'التغطية لا تشمل كامل الفيديو أو لا تطابق المصدر.']);
}
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, j.video_sha256, j.markdown_sha256,
vv.uuid AS version_uuid, vv.status AS version_status, vv.content_sha256,
vv.source_lesson_id, vv.replaces_video_version_id,
ts.id AS submission_id, ts.curriculum_lesson_id,
ts.current_published_video_version_id, cl.source_status,
l.encoding_status, l.duration_seconds,
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
JOIN curriculum_lessons cl ON cl.id=ts.curriculum_lesson_id
LEFT JOIN lessons l ON l.id=vv.source_lesson_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,recommendation,reviewer_id,report_json FROM video_review_reports WHERE video_review_job_id = ? FOR UPDATE', [$job['id']]);
$evidence = Database::selectOne('SELECT id,coverage_json FROM video_review_evidence WHERE video_review_job_id=? FOR UPDATE', [$job['id']]);
if (($job['status'] ?? '') !== 'approved' || ($report['human_decision'] ?? '') !== 'approved' ||
($report['recommendation'] ?? '') !== 'ready_for_human_review' || empty($report['reviewer_id']) || !$evidence) {
return self::finish($pdo, ['status' => 'review_not_approved', 'http_status' => 409, 'message' => 'لا يمكن النشر قبل تقرير مكتمل وموافقة مراجع بشري.']);
}
$reportData = json_decode((string)$report['report_json'], true);
$savedCoverage = json_decode((string)$evidence['coverage_json'], true);
if (!is_array($reportData) || !is_array($savedCoverage) ||
!VideoReviewReportValidator::approvable($reportData) ||
!VideoCoverageValidator::valid($savedCoverage, (int)$job['duration_seconds'], (string)$job['video_sha256']) ||
!hash_equals((string)$job['markdown_sha256'], (string)($savedCoverage['markdown_sha256'] ?? '')) ||
($reportData['coverage']['truncated'] ?? true) !== false ||
!empty($reportData['coverage']['unprocessed_intervals'])) {
return self::finish($pdo, ['status'=>'stale_evidence','http_status'=>409,'message'=>'أدلة الفيديو أو التقرير لم تعد صالحة للنشر.']);
}
if (($job['version_status'] ?? '') !== 'review' || ($job['source_status'] ?? '') !== 'approved' ||
($job['encoding_status'] ?? '') !== 'ready' || empty($job['source_lesson_id']) ||
!hash_equals((string)$job['video_sha256'], (string)($job['content_sha256'] ?? '')) ||
$job['video_sha256'] === str_repeat('0', 64) || $job['markdown_sha256'] === str_repeat('0', 64)) {
return self::finish($pdo, ['status'=>'version_not_ready','http_status'=>409,'message'=>'نسخة الفيديو أو درسها غير جاهزين للنشر.']);
}
$markdown = Database::selectOne(
"SELECT a.id FROM publication_bundles pb
JOIN publication_bundle_assets pba ON pba.publication_bundle_id=pb.id AND pba.role='primary_lesson'
JOIN content_assets a ON a.id=pba.content_asset_id
WHERE pb.curriculum_lesson_id=? AND pb.status='published'
AND a.sha256=? AND a.review_status='approved' AND a.rights_status='cleared'
LIMIT 1 FOR UPDATE",
[(int)$job['curriculum_lesson_id'], $job['markdown_sha256']]
);
if (!$markdown) return self::finish($pdo, ['status'=>'source_changed','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 (($job['current_published_video_version_id'] === null) !== ($job['replaces_video_version_id'] === null) ||
($job['current_published_video_version_id'] !== null && (int)$job['current_published_video_version_id'] !== (int)$job['replaces_video_version_id']) ||
($job['current_published_video_version_id'] !== null && $expectedCurrentVersionUuid === null)) {
return self::finish($pdo, ['status'=>'current_version_conflict','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;
}
}