290 lines
11 KiB
PHP
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"));
|
|
}
|