lesson, …). * - history legacy frontmatter is migrated to canonical keys using values * already present in the file (title, page range, source file) plus the * manifest/path identity. Unverifiable "verified_authentic" claims without * a recorded reviewer are downgraded to needs_human_review and reported. * - files without any frontmatter (civics, geography, 3 history files) get a * canonical block built from the manifest identity + the page range stated * in the file body + the subject/semester PDF map. Bodies are untouched. * - quarantined files (empty physics shells, duplicated english lesson) are * left byte-identical and only reported. * * Usage: * php backend/scripts/repair_grade10_candidate.php # applies repairs * * Old versions remain available via git history for review. */ $projectRoot = dirname(__DIR__, 2); $intakeRoot = $projectRoot . '/backend/storage/curriculum/_incoming'; $candidateRoot = $intakeRoot . '/grade_10'; $manifestPath = $candidateRoot . '/manifest.candidate.json'; $booksRoot = $projectRoot . '/books'; $QUARANTINED = [ 'grade_10/physics_10/semester_1/unit_03/lesson_03.md', 'grade_10/physics_10/semester_1/unit_03/unit_review.md', 'grade_10/english_10/semester_1/unit_01/lesson_07.md', ]; $PDF_MAP = [ 'civics_10/semester_1' => 'كتاب الطالب لمادة التربية الوطنية والمدنية للصف العاشر الفصل الأول.pdf', 'civics_10/semester_2' => 'كتاب الطالب لمادة التربية الوطنية والمدنية للصف العاشر الفصل الثاني.pdf', 'geography_10/semester_1' => 'كتاب الطالب لمادة الجغرافيا للصف العاشر الفصل الأول.pdf', 'geography_10/semester_2' => 'كتاب الطالب لمادة الجغرافيا للصف العاشر الفصل الثاني.pdf', 'history_10/semester_2' => 'كتاب الطالب لمادة التاريخ للصف العاشر الفصل الثاني.pdf', ]; $actions = []; $skipped = []; $downgraded = []; $manifest = json_decode((string) file_get_contents($manifestPath), true); $byFile = []; foreach ($manifest['lessons'] as $entry) { $byFile[$entry['file']] = $entry; } foreach ($byFile as $relativePath => $entry) { if (in_array($relativePath, $QUARANTINED, true)) { $skipped[] = ['file' => $relativePath, 'reason' => 'quarantined_for_human_review']; continue; } $abs = $intakeRoot . '/' . $relativePath; if (!is_file($abs)) { $skipped[] = ['file' => $relativePath, 'reason' => 'missing_on_disk']; continue; } $markdown = (string) file_get_contents($abs); $stem = pathinfo($relativePath, PATHINFO_FILENAME); $resourceType = resourceTypeFor($stem); if (!preg_match('/\A---\r?\n(?.*?)\r?\n---\r?\n/s', $markdown, $m)) { buildFrontmatter($abs, $relativePath, $entry, $resourceType); continue; } $yaml = $m['yaml']; if (str_contains($yaml, 'grade_key')) { $changed = false; if (!preg_match('/^resource_type:\s*\S+/m', $yaml)) { $yaml = insertAfter($yaml, '/^(lesson_key|curriculum_version):\s*.+$/m', 'resource_type: ' . $resourceType); $changed = true; $actions[] = ['file' => $relativePath, 'action' => 'resource_type_added:' . $resourceType]; } if (preg_match('/^(\s{2}extraction_review_status:\s*)"verified_authentic"/m', $yaml) && !preg_match('/^reviewer:\s*\S+/m', $yaml)) { $yaml = preg_replace( '/^(\s{2}extraction_review_status:\s*)"verified_authentic"/m', '$1"needs_human_review"', $yaml ); $changed = true; $downgraded[] = $relativePath; $actions[] = ['file' => $relativePath, 'action' => 'authenticity_downgraded_to_needs_human_review']; } if ($changed) { writeWithYaml($abs, $markdown, $m[0], $yaml); } continue; } migrateLegacy($abs, $relativePath, $entry, $resourceType, $markdown, $m); } echo "Repair complete: " . count($actions) . " actions, " . count($skipped) . " skipped, " . count($downgraded) . " authenticity downgrades.\n"; foreach ($skipped as $s) { echo 'SKIP ' . $s['file'] . ' — ' . $s['reason'] . "\n"; } foreach ($downgraded as $f) { echo 'DOWNGRADE ' . $f . " — original claim verified_authentic without reviewer record\n"; } function resourceTypeFor(string $stem): string { if (str_starts_with($stem, 'lesson_')) { return 'lesson'; } return match ($stem) { 'unit_review' => 'unit_review', 'unit_exam' => 'worksheet', 'geogebra_lab' => 'lab', 'intro_and_project', 'unit_test_and_project' => 'project', default => 'special_resource', }; } function yamlQuote(string $value): string { return '"' . addcslashes($value, '"\\') . '"'; } function pdfPageCount(string $path): ?int { $output = []; $status = 0; exec('pdfinfo ' . escapeshellarg($path) . ' 2>/dev/null', $output, $status); if ($status !== 0) { return null; } foreach ($output as $line) { if (preg_match('/^Pages:\s+(\d+)$/', $line, $mm)) { return (int) $mm[1]; } } return null; } /** @param array $entry */ function buildFrontmatter(string $abs, string $relativePath, array $entry, string $resourceType): void { global $actions, $skipped, $booksRoot, $PDF_MAP; $markdown = (string) file_get_contents($abs); $key = $entry['subject_key'] . '/' . $entry['semester_key']; if (!isset($PDF_MAP[$key])) { $skipped[] = ['file' => $relativePath, 'reason' => 'no_pdf_mapping_for:' . $key]; return; } $pdf = $PDF_MAP[$key]; $range = bodyPageRange($markdown); if ($range === null) { $skipped[] = ['file' => $relativePath, 'reason' => 'no_page_range_in_body']; return; } [$start, $end] = $range; $count = pdfPageCount($booksRoot . '/' . $pdf); if ($count === null || $start < 1 || $end > $count) { $skipped[] = ['file' => $relativePath, 'reason' => 'body_pages_out_of_range:' . $start . '-' . $end . '_of_' . $count]; return; } $pages = implode(', ', range($start, $end)); $block = "---\n" . 'grade_key: ' . $entry['grade_key'] . "\n" . 'subject_key: ' . $entry['subject_key'] . "\n" . 'semester_key: ' . $entry['semester_key'] . "\n" . 'unit_key: ' . $entry['unit_key'] . "\n" . 'lesson_key: ' . $entry['lesson_key'] . "\n" . 'curriculum_version: ' . $entry['curriculum_version'] . "\n" . 'title: ' . yamlQuote((string) $entry['title']) . "\n" . 'resource_type: ' . $resourceType . "\n" . "source:\n" . ' original_pdf: ' . yamlQuote($pdf) . "\n" . ' pages: [' . $pages . "]\n" . ' extraction_method: "unrecorded"' . "\n" . ' extraction_review_status: "needs_human_review"' . "\n" . "---\n\n"; file_put_contents($abs, $block . $markdown); $actions[] = ['file' => $relativePath, 'action' => 'frontmatter_built:pages_' . $start . '-' . $end]; } /** Derive [start,end] from the page range stated in the file body. */ function bodyPageRange(string $markdown): ?array { $patterns = [ '/الصفحات\D{0,8}(\d+)\s*[-–—]\s*(\d+)/u', '/\(صفحة\s*(\d+)\s*[-–—]\s*(\d+)\)/u', '/صفحة\s*(\d+)\s*[-–—]\s*(\d+)/u', ]; foreach ($patterns as $pattern) { if (preg_match($pattern, $markdown, $m)) { $a = (int) $m[1]; $b = (int) $m[2]; if ($a >= 1 && $b >= $a && ($b - $a) <= 40) { return [$a, $b]; } } } if (preg_match_all('/صفحة\s*(\d+)/u', $markdown, $m) && $m[1] !== []) { $nums = array_map('intval', $m[1]); $a = min($nums); $b = max($nums); if ($a >= 1 && ($b - $a) <= 40) { return [$a, $b]; } } return null; } /** @param array $entry */ function migrateLegacy(string $abs, string $relativePath, array $entry, string $resourceType, string $markdown, array $m): void { global $actions, $skipped, $booksRoot, $PDF_MAP, $downgraded; $yaml = $m['yaml']; $title = null; if (preg_match('/^lesson_title_ar:\s*"?(.*?)"?\s*$/mu', $yaml, $tm)) { $title = trim($tm[1], '"'); } elseif (preg_match('/^title:\s*"?(.*?)"?\s*$/mu', $yaml, $tm)) { $title = trim($tm[1], '"'); } if ($title === null || $title === '') { $title = (string) $entry['title']; } $pdf = null; if (preg_match('/^source_file:\s*"?(.*?)"?\s*$/mu', $yaml, $sm)) { $pdf = trim($sm[1], '"'); } else { $key = $entry['subject_key'] . '/' . $entry['semester_key']; $pdf = $PDF_MAP[$key] ?? null; } $range = null; if (preg_match('/textbook_page_range:\s*"(\d+)-(\d+)"/', $yaml, $rm)) { $range = [(int) $rm[1], (int) $rm[2]]; } elseif (preg_match('/start:\s*(\d+).*?end:\s*(\d+)/s', $yaml, $rm)) { $range = [(int) $rm[1], (int) $rm[2]]; } $method = 'unrecorded'; if (preg_match('/^extraction_method:\s*"?(.*?)"?\s*$/mu', $yaml, $em)) { $method = trim($em[1], '"'); } if ($pdf === null || $range === null) { $skipped[] = ['file' => $relativePath, 'reason' => 'legacy_without_source_or_range']; return; } [$start, $end] = $range; $count = pdfPageCount($booksRoot . '/' . $pdf); if ($count === null || $start < 1 || $end > $count) { $skipped[] = ['file' => $relativePath, 'reason' => 'legacy_pages_out_of_range:' . $start . '-' . $end . '_of_' . $count]; return; } if (preg_match('/extraction_review_status:\s*"verified_authentic"/', $yaml)) { $downgraded[] = $relativePath . ' (legacy claim without reviewer record)'; } $pages = implode(', ', range($start, $end)); $newYaml = 'grade_key: ' . $entry['grade_key'] . "\n" . 'subject_key: ' . $entry['subject_key'] . "\n" . 'semester_key: ' . $entry['semester_key'] . "\n" . 'unit_key: ' . $entry['unit_key'] . "\n" . 'lesson_key: ' . $entry['lesson_key'] . "\n" . 'curriculum_version: ' . $entry['curriculum_version'] . "\n" . 'title: ' . yamlQuote($title) . "\n" . 'resource_type: ' . $resourceType . "\n" . "source:\n" . ' original_pdf: ' . yamlQuote($pdf) . "\n" . ' pages: [' . $pages . "]\n" . ' extraction_method: ' . yamlQuote($method) . "\n" . ' extraction_review_status: "needs_human_review"' . "\n"; writeWithYaml($abs, $markdown, $m[0], $newYaml); $actions[] = ['file' => $relativePath, 'action' => 'legacy_migrated:pages_' . $start . '-' . $end]; } function insertAfter(string $yaml, string $pattern, string $line): string { $result = preg_replace($pattern, '$0' . "\n" . $line, $yaml, 1); return is_string($result) ? $result : $yaml; } function writeWithYaml(string $abs, string $markdown, string $oldBlock, string $newYaml): void { $rest = substr($markdown, strlen($oldBlock)); file_put_contents($abs, "---\n" . rtrim($newYaml, "\n") . "\n---\n" . ltrim($rest, "\n")); }