160 lines
12 KiB
PHP
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;
|
|
}
|
|
}
|