Files
saqel/backend/scripts/repair_grade10_candidate.php
T

290 lines
11 KiB
PHP

<?php
declare(strict_types=1);
/**
* Conservative metadata repair for the Grade 10 candidate intake bundle.
*
* It never invents lesson content, pages, or learning outcomes:
* - resource_type is derived from the filename stem (lesson_NN => 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(?<yaml>.*?)\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<string,mixed> $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<string,mixed> $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"));
}