Update Saqel Platform: 2026-09-10 23:13:50

This commit is contained in:
Hamza-Ayed
2026-09-10 23:13:50 +03:00
parent a742c5bfd6
commit 12fb6d07e6
376 changed files with 7606 additions and 490 deletions
@@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
/**
* Safe promotion path for the Grade 10 candidate intake bundle.
*
* _incoming/grade_10 ---> content_assets + publication_bundles (draft only)
*
* Safety rules (enforced by this script, not by convention):
* - Default mode is a read-only dry run: it re-runs the strict intake gate
* on the chosen manifest and prints the staging plan. It writes nothing.
* - --apply requires the gate to PASS for the chosen manifest, otherwise it
* refuses. The full candidate manifest is therefore un promotable until
* quarantined files are resolved by humans.
* - --apply only creates `draft` bundles/assets with `review_required` /
* `draft` review status. It never publishes, never approves rights, never
* writes lessons.markdown_content, and never touches the live manifest.json.
* - Files are staged content-addressed (sha256) so re-runs are idempotent.
* - No exercise, lab, simulation, or question generation happens here. Those
* require a published, reviewed bundle (see docs/GRADE10_CANDIDATE_INTAKE_GATE.md).
*
* Usage:
* php backend/scripts/promote_grade10_candidate.php
* php backend/scripts/promote_grade10_candidate.php --manifest=manifest.accepted.json
* php backend/scripts/promote_grade10_candidate.php --manifest=manifest.accepted.json --apply
*/
$apply = in_array('--apply', $argv, true);
$manifestName = 'manifest.accepted.json';
foreach ($argv as $arg) {
if (str_starts_with($arg, '--manifest=')) {
$manifestName = substr($arg, strlen('--manifest='));
}
}
$projectRoot = dirname(__DIR__, 2);
$intakeRoot = $projectRoot . '/backend/storage/curriculum/_incoming';
$candidateRoot = $intakeRoot . '/grade_10';
$stageRoot = $projectRoot . '/backend/storage/curriculum/_staging/grade_10';
$manifestPath = $candidateRoot . '/' . basename($manifestName);
$validator = $projectRoot . '/backend/scripts/validate_grade10_candidate.php';
// 1. Mandatory gate preflight: identical strict rules, no exceptions.
$gateJson = shell_exec(
'php ' . escapeshellarg($validator) . ' --json --manifest=' . escapeshellarg(basename($manifestName)) . ' 2>/dev/null'
);
$gate = json_decode((string) $gateJson, true);
if (!is_array($gate) || ($gate['status'] ?? null) !== 'passed') {
fwrite(STDERR, "Promotion refused: intake gate did not pass for {$manifestName}.\n");
fwrite(STDERR, "Resolve review-queue.json items first; quarantined files stay unpublished.\n");
exit(1);
}
$manifest = json_decode((string) file_get_contents($manifestPath), true);
$entries = $manifest['lessons'] ?? [];
$curriculumVersion = (string) ($manifest['curriculum_version'] ?? 'unknown');
$bundleVersion = 'grade10-intake-2026-09-10';
$plan = [];
foreach ($entries as $entry) {
$relativePath = (string) ($entry['file'] ?? '');
if (!preg_match('#\Agrade_10/[a-z0-9_]+/semester_[12]/unit_[0-9]+/[a-z0-9_]+\.md\z#', $relativePath)) {
fwrite(STDERR, "Promotion refused: unsafe file path in manifest: {$relativePath}\n");
exit(1);
}
$abs = $intakeRoot . '/' . $relativePath;
$sha = hash_file('sha256', $abs);
$bytes = filesize($abs);
if ($sha === false || $bytes === false) {
fwrite(STDERR, "Promotion refused: unreadable file: {$relativePath}\n");
exit(1);
}
$frontmatter = parseFrontmatter((string) file_get_contents($abs));
$resourceType = (string) ($frontmatter['resource_type'] ?? '');
$source = $frontmatter['source'] ?? [];
$pages = is_array($source['pages'] ?? null) ? $source['pages'] : [];
$plan[] = [
'file' => $relativePath,
'grade_key' => $entry['grade_key'],
'subject_key' => $entry['subject_key'],
'semester_key' => $entry['semester_key'],
'unit_key' => $entry['unit_key'],
'lesson_key' => $entry['lesson_key'],
'title' => $entry['title'],
'resource_type' => $resourceType,
'source_reference' => 'books/' . ($source['original_pdf'] ?? '?') . '#pages-' . implode(',', $pages),
'sha256' => $sha,
'byte_size' => $bytes,
'storage_key' => sprintf('staged/grade_10/%s/%s', substr($sha, 0, 12), $relativePath),
'asset_type' => $resourceType === 'lesson' ? 'lesson_markdown' : 'supporting_markdown',
'bundle_role' => $resourceType === 'lesson' ? 'primary_lesson' : 'supporting_resource',
];
}
$report = [
'mode' => $apply ? 'apply' : 'dry_run',
'manifest' => basename($manifestName),
'curriculum_version' => $curriculumVersion,
'bundle_version' => $bundleVersion,
'bundle_status' => 'draft',
'asset_review_status' => 'draft',
'policy' => 'Draft staging only. No publication, no rights clearance, no lessons.markdown_content writes, no exercise/lab generation.',
'entries' => count($plan),
];
if (!$apply) {
$report['plan'] = $plan;
echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL;
exit(0);
}
require_once dirname(__DIR__) . '/app/bootstrap.php';
use App\Core\Database;
$pdo = Database::getConnection();
$pdo->beginTransaction();
try {
foreach ($plan as $row) {
$source = $candidateRoot . '/' . $row['file'];
$destination = $projectRoot . '/backend/storage/curriculum/' . $row['storage_key'];
if (!is_dir(dirname($destination)) && !mkdir(dirname($destination), 0750, true) && !is_dir(dirname($destination))) {
throw new RuntimeException('Unable to create staging directory.');
}
if (!copy($source, $destination) || !hash_equals($row['sha256'], hash_file('sha256', $destination))) {
throw new RuntimeException('Staged copy checksum mismatch: ' . $row['file']);
}
Database::query(
"INSERT INTO curriculum_lessons
(uuid, grade_key, subject_key, semester_key, unit_key, lesson_key, curriculum_version, title, source_manifest_path, source_status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'unverified')
ON DUPLICATE KEY UPDATE title = VALUES(title), source_manifest_path = VALUES(source_manifest_path)",
[selfUuid(), $row['grade_key'], $row['subject_key'], $row['semester_key'], $row['unit_key'], $row['lesson_key'], $curriculumVersion, $row['title'], $row['file']]
);
$lessonId = (int) Database::selectOne(
'SELECT id FROM curriculum_lessons WHERE grade_key = ? AND subject_key = ? AND semester_key = ? AND unit_key = ? AND lesson_key = ? AND curriculum_version = ? LIMIT 1',
[$row['grade_key'], $row['subject_key'], $row['semester_key'], $row['unit_key'], $row['lesson_key'], $curriculumVersion]
)['id'];
Database::query(
"INSERT INTO publication_bundles (uuid, curriculum_lesson_id, bundle_version, status)
VALUES (?, ?, ?, 'draft')
ON DUPLICATE KEY UPDATE id = id",
[selfUuid(), $lessonId, $bundleVersion]
);
$bundleId = (int) Database::selectOne(
'SELECT id FROM publication_bundles WHERE curriculum_lesson_id = ? AND bundle_version = ? LIMIT 1',
[$lessonId, $bundleVersion]
)['id'];
Database::query(
"INSERT INTO content_assets (uuid, asset_type, storage_driver, storage_key, mime_type, byte_size, sha256, source_reference, rights_status, review_status)
VALUES (?, ?, 'local', ?, 'text/markdown; charset=utf-8', ?, ?, ?, 'review_required', 'draft')
ON DUPLICATE KEY UPDATE byte_size = VALUES(byte_size), source_reference = VALUES(source_reference)",
[selfUuid(), $row['asset_type'], $row['storage_key'], $row['byte_size'], $row['sha256'], $row['source_reference']]
);
$assetId = (int) Database::selectOne(
'SELECT id FROM content_assets WHERE storage_driver = ? AND storage_key = ? AND sha256 = ? LIMIT 1',
['local', $row['storage_key'], $row['sha256']]
)['id'];
Database::query(
'INSERT IGNORE INTO publication_bundle_assets (publication_bundle_id, content_asset_id, role) VALUES (?, ?, ?)',
[$bundleId, $assetId, $row['bundle_role']]
);
}
$pdo->commit();
$report['staged_draft_entries'] = count($plan);
echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
fwrite(STDERR, 'Promotion rolled back: ' . $e->getMessage() . "\n");
exit(1);
}
function parseFrontmatter(string $markdown): array
{
if (!preg_match('/\A---\r?\n(?<yaml>.*?)\r?\n---\r?\n/s', $markdown, $match)) {
return [];
}
$result = [];
$section = null;
foreach (preg_split('/\r?\n/', $match['yaml']) as $line) {
if (preg_match('/^(\w+):\s*$/', $line, $sectionMatch)) {
$section = $sectionMatch[1];
$result[$section] = [];
continue;
}
if (preg_match('/^\s{2}(\w+):\s*(.+)$/', $line, $nestedMatch) && $section !== null) {
$raw = trim($nestedMatch[2]);
if (str_starts_with($raw, '[') && str_ends_with($raw, ']')) {
$inner = trim(substr($raw, 1, -1));
$result[$section][$nestedMatch[1]] = $inner === ''
? []
: array_map(static fn (string $item): int|string => ctype_digit(trim($item)) ? (int) trim($item) : trim($item), explode(',', $inner));
} else {
$result[$section][$nestedMatch[1]] = trim($raw, '"');
}
continue;
}
if (preg_match('/^(\w+):\s*(.+)$/', $line, $scalarMatch)) {
$section = null;
$result[$scalarMatch[1]] = trim($scalarMatch[2], '"');
}
}
return $result;
}
function selfUuid(): 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));
}
@@ -0,0 +1,289 @@
<?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"));
}
@@ -0,0 +1,430 @@
<?php
declare(strict_types=1);
/**
* Read-only gate for the Grade 10 curriculum intake bundle.
*
* It deliberately does not connect to MySQL, move files, or publish content.
* A non-zero exit code means the candidate must remain in _incoming.
*
* Usage:
* php backend/scripts/validate_grade10_candidate.php
* php backend/scripts/validate_grade10_candidate.php --json
* php backend/scripts/validate_grade10_candidate.php --json --manifest=manifest.accepted.json
*
* The optional --manifest flag gates a named package (e.g. an accepted subset)
* with the exact same strict rules. It does not relax any rule.
*/
$jsonOutput = in_array('--json', $argv, true);
$manifestOverride = null;
foreach ($argv as $arg) {
if (str_starts_with($arg, '--manifest=')) {
$manifestOverride = substr($arg, strlen('--manifest='));
}
}
$projectRoot = dirname(__DIR__, 2);
$intakeRoot = $projectRoot . '/backend/storage/curriculum/_incoming';
$candidateRoot = $intakeRoot . '/grade_10';
$manifestPath = $manifestOverride !== null && $manifestOverride !== ''
? (str_starts_with($manifestOverride, '/') ? $manifestOverride : $candidateRoot . '/' . ltrim($manifestOverride, '/'))
: $candidateRoot . '/manifest.candidate.json';
$qaReportPath = $candidateRoot . '/qa-report.json';
$booksRoot = $projectRoot . '/books';
$issues = [];
$summary = [
'manifest' => basename($manifestPath),
'manifest_entries' => 0,
'markdown_files' => 0,
'content_types' => [],
'resource_types' => [],
'source_references_checked' => 0,
'qa_claim_conflicts' => [],
];
const RESOURCE_TYPES = ['lesson', 'unit_review', 'worksheet', 'lab', 'project', 'special_resource'];
const REVIEW_STATUSES = ['needs_human_review', 'in_human_review', 'verified_authentic', 'rejected'];
if (!is_file($manifestPath)) {
fail('manifest_missing', 'Candidate manifest is missing.', ['path' => $manifestPath]);
finish();
}
$manifest = json_decode((string) file_get_contents($manifestPath), true);
if (!is_array($manifest) || !is_array($manifest['lessons'] ?? null)) {
fail('manifest_invalid', 'Candidate manifest must contain a lessons array.', ['path' => $manifestPath]);
finish();
}
$entries = $manifest['lessons'];
$summary['manifest_entries'] = count($entries);
$entryPaths = [];
$entryIds = [];
$bodyHashes = [];
$actualMarkdown = [];
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($candidateRoot, FilesystemIterator::SKIP_DOTS)) as $file) {
if ($file->isFile() && strtolower($file->getExtension()) === 'md') {
$actualMarkdown[] = relativePath($file->getPathname(), $intakeRoot);
}
}
sort($actualMarkdown);
$summary['markdown_files'] = count($actualMarkdown);
foreach ($entries as $index => $entry) {
if (!is_array($entry)) {
fail('manifest_entry_invalid', 'A manifest lesson entry is not an object.', ['index' => $index]);
continue;
}
$relativePath = (string) ($entry['file'] ?? '');
$entryId = (string) ($entry['id'] ?? '');
if ($relativePath === '' || $entryId === '') {
fail('manifest_identity_missing', 'A manifest entry is missing id or file.', ['index' => $index]);
continue;
}
$entryPaths[$relativePath][] = $index;
$entryIds[$entryId][] = $index;
$path = resolveWithin($intakeRoot, $relativePath);
if ($path === null || !is_file($path)) {
fail('markdown_missing', 'Manifest file does not exist inside intake storage.', ['file' => $relativePath]);
continue;
}
$type = contentType($relativePath);
$summary['content_types'][$type] = ($summary['content_types'][$type] ?? 0) + 1;
$markdown = (string) file_get_contents($path);
$frontmatter = parseFrontmatter($markdown);
if ($frontmatter === null) {
fail('frontmatter_missing', 'Markdown must start with canonical YAML frontmatter.', ['file' => $relativePath]);
continue;
}
$expectedKeys = ['grade_key', 'subject_key', 'semester_key', 'unit_key', 'lesson_key', 'curriculum_version'];
foreach ($expectedKeys as $key) {
if (($frontmatter[$key] ?? null) !== ($entry[$key] ?? null)) {
fail('frontmatter_identity_mismatch', 'Frontmatter identity does not match the manifest.', [
'file' => $relativePath,
'field' => $key,
'manifest' => $entry[$key] ?? null,
'frontmatter' => $frontmatter[$key] ?? null,
]);
}
}
if (($frontmatter['title'] ?? null) !== ($entry['title'] ?? null)) {
warn('title_mismatch', 'Frontmatter title differs from manifest title.', ['file' => $relativePath]);
}
if (!is_string($frontmatter['title'] ?? null) || trim((string) ($frontmatter['title'] ?? '')) === '') {
fail('title_missing', 'Canonical frontmatter title is required.', ['file' => $relativePath]);
}
$resourceType = $frontmatter['resource_type'] ?? null;
if (!is_string($resourceType) || $resourceType === '') {
fail('resource_type_missing', 'Explicit resource_type is required: lesson, unit_review, worksheet, lab, project, or special_resource.', ['file' => $relativePath]);
} elseif (!in_array($resourceType, RESOURCE_TYPES, true)) {
fail('resource_type_invalid', 'resource_type must use the canonical vocabulary.', ['file' => $relativePath, 'resource_type' => $resourceType]);
} else {
$summary['resource_types'][$resourceType] = ($summary['resource_types'][$resourceType] ?? 0) + 1;
$stem = pathinfo($relativePath, PATHINFO_FILENAME);
$isLessonFile = str_starts_with($stem, 'lesson_');
if ($isLessonFile && $resourceType !== 'lesson') {
fail('resource_type_filename_conflict', 'A lesson_NN file must be typed lesson, not a review/lab/project.', ['file' => $relativePath, 'resource_type' => $resourceType]);
}
if (!$isLessonFile && $resourceType === 'lesson') {
fail('resource_type_filename_conflict', 'A review/lab/project/worksheet file must never be typed as a video lesson.', ['file' => $relativePath, 'resource_type' => $resourceType]);
}
}
$pathSegments = explode('/', $relativePath);
if (count($pathSegments) === 5) {
[$pathGrade, $pathSubject, $pathSemester, $pathUnit, $pathFile] = $pathSegments;
$pathLesson = pathinfo($pathFile, PATHINFO_FILENAME);
$pathIdentity = [
'grade_key' => $pathGrade,
'subject_key' => $pathSubject,
'semester_key' => $pathSemester,
'unit_key' => $pathUnit,
'lesson_key' => $pathLesson,
];
foreach ($pathIdentity as $key => $pathValue) {
if (($entry[$key] ?? null) !== $pathValue) {
fail('path_identity_mismatch', 'Manifest identity does not match the file path.', [
'file' => $relativePath,
'field' => $key,
'manifest' => $entry[$key] ?? null,
'path' => $pathValue,
]);
}
if (($frontmatter[$key] ?? null) !== $pathValue) {
fail('path_identity_mismatch', 'Frontmatter identity does not match the file path.', [
'file' => $relativePath,
'field' => $key,
'frontmatter' => $frontmatter[$key] ?? null,
'path' => $pathValue,
]);
}
}
} else {
fail('path_identity_mismatch', 'Candidate file must live at grade_10/<subject>/<semester>/<unit>/<lesson>.md.', ['file' => $relativePath]);
}
$source = $frontmatter['source'] ?? null;
if (!is_array($source) || !is_string($source['original_pdf'] ?? null) || !is_array($source['pages'] ?? null)) {
fail('source_metadata_missing', 'Canonical source.original_pdf and source.pages are required.', ['file' => $relativePath]);
} else {
$method = $source['extraction_method'] ?? null;
$reviewStatus = $source['extraction_review_status'] ?? null;
if (!is_string($method) || trim($method) === '' || !is_string($reviewStatus) || trim($reviewStatus) === '') {
fail('source_extraction_missing', 'source.extraction_method and source.extraction_review_status are required.', ['file' => $relativePath]);
} elseif (!in_array($reviewStatus, REVIEW_STATUSES, true)) {
warn('source_review_status_unknown', 'Unknown extraction review status; use the canonical vocabulary.', ['file' => $relativePath, 'status' => $reviewStatus]);
} elseif ($reviewStatus === 'verified_authentic') {
warn('authenticity_unverified', 'File claims verified_authentic without a recorded reviewer; academic review must confirm it.', ['file' => $relativePath]);
}
validatePages($source['original_pdf'], $source['pages'], $relativePath);
}
$content = lessonContent($markdown);
if ($content === '') {
fail('lesson_content_empty', 'The lesson content section is empty.', ['file' => $relativePath]);
} else {
$bodyHashes[hash('sha256', normalizeBody($content))][] = $relativePath;
}
if (preg_match('/[\x{202A}-\x{202E}\x{2066}-\x{2069}]/u', $markdown)) {
warn('bidirectional_control_characters', 'Markdown contains invisible bidirectional control characters.', ['file' => $relativePath]);
}
if (str_contains($markdown, "�")) {
warn('replacement_character', 'Markdown contains replacement characters from failed extraction.', ['file' => $relativePath]);
}
if (str_contains($markdown, 'نتاجات التعلم المستخلصة من أهداف ومحاور الدرس')) {
warn('generic_learning_outcomes', 'Learning outcomes are a placeholder, not lesson-specific outcomes.', ['file' => $relativePath]);
}
}
foreach ($entryPaths as $path => $indexes) {
if (count($indexes) > 1) {
fail('manifest_path_duplicate', 'More than one manifest entry uses the same file path.', ['file' => $path, 'indexes' => $indexes]);
}
}
foreach ($entryIds as $id => $indexes) {
if (count($indexes) > 1) {
fail('manifest_id_duplicate', 'More than one manifest entry uses the same ID.', ['id' => $id, 'indexes' => $indexes]);
}
}
$listedPaths = array_keys($entryPaths);
sort($listedPaths);
$isSubsetMode = basename($manifestPath) !== 'manifest.candidate.json';
foreach (array_diff($actualMarkdown, $listedPaths) as $path) {
if ($isSubsetMode) {
warn('markdown_not_in_scope', 'Markdown exists on disk but is outside the gated package; it must stay unpublished until reviewed.', ['file' => $path]);
} else {
fail('markdown_unlisted', 'Markdown exists in the candidate directory but is absent from the manifest.', ['file' => $path]);
}
}
foreach (array_diff($listedPaths, $actualMarkdown) as $path) {
fail('markdown_missing', 'Manifest references a missing Markdown file.', ['file' => $path]);
}
foreach ($bodyHashes as $hash => $paths) {
if (count($paths) > 1) {
fail('lesson_body_duplicate', 'Two candidate entries have the same normalized lesson content.', ['files' => $paths]);
}
}
if (is_file($qaReportPath)) {
$qa = json_decode((string) file_get_contents($qaReportPath), true);
$claims = $qa['validation'] ?? [];
$frontmatterErrors = countIssues('frontmatter_missing') + countIssues('frontmatter_identity_mismatch');
$emptyErrors = countIssues('lesson_content_empty');
if (($claims['valid_yaml_frontmatters'] ?? null) === true && $frontmatterErrors > 0) {
$summary['qa_claim_conflicts'][] = 'valid_yaml_frontmatters';
fail('qa_claim_conflict', 'QA says all frontmatters are valid, but the validator found incompatible metadata.', ['claim' => 'valid_yaml_frontmatters', 'errors' => $frontmatterErrors]);
}
if (($claims['no_empty_files'] ?? null) === true && $emptyErrors > 0) {
$summary['qa_claim_conflicts'][] = 'no_empty_files';
fail('qa_claim_conflict', 'QA says no files are empty, but the validator found empty lesson content.', ['claim' => 'no_empty_files', 'errors' => $emptyErrors]);
}
}
finish();
function parseFrontmatter(string $markdown): ?array
{
// Do not use PCRE \R here: in non-Unicode mode it may treat bytes inside
// Arabic UTF-8 text as line separators. Candidate files use LF/CRLF.
if (!preg_match('/\A---\r?\n(?<yaml>.*?)\r?\n---\r?\n/s', $markdown, $match)) {
return null;
}
$result = [];
$section = null;
foreach (preg_split('/\r?\n/', $match['yaml']) as $line) {
if (preg_match('/^(\w+):\s*$/', $line, $sectionMatch)) {
$section = $sectionMatch[1];
$result[$section] = [];
continue;
}
if (preg_match('/^\s{2}(\w+):\s*(.+)$/', $line, $nestedMatch) && $section !== null) {
$result[$section][$nestedMatch[1]] = parseYamlValue($nestedMatch[2]);
continue;
}
if (preg_match('/^(\w+):\s*(.+)$/', $line, $scalarMatch)) {
$section = null;
$result[$scalarMatch[1]] = parseYamlValue($scalarMatch[2]);
}
}
return $result;
}
function parseYamlValue(string $value): mixed
{
$value = trim($value);
if ((str_starts_with($value, '"') && str_ends_with($value, '"')) || (str_starts_with($value, "'") && str_ends_with($value, "'"))) {
return substr($value, 1, -1);
}
if (str_starts_with($value, '[') && str_ends_with($value, ']')) {
$items = trim(substr($value, 1, -1));
return $items === '' ? [] : array_map(static fn (string $item): int|string => ctype_digit(trim($item)) ? (int) trim($item) : trim($item), explode(',', $items));
}
return ctype_digit($value) ? (int) $value : $value;
}
function validatePages(string $pdfName, array $pages, string $file): void
{
global $booksRoot, $summary;
if ($pages === []) {
fail('source_pages_empty', 'source.pages must list at least one source page.', ['file' => $file]);
return;
}
foreach ($pages as $page) {
if (!is_int($page) || $page < 1) {
fail('source_page_invalid', 'source.pages must contain positive integers.', ['file' => $file, 'page' => $page]);
return;
}
}
if (count(array_unique($pages)) !== count($pages)) {
warn('source_pages_unsorted', 'source.pages contains duplicates; keep one sorted entry per page.', ['file' => $file]);
}
$sorted = $pages;
sort($sorted);
if ($sorted !== $pages) {
warn('source_pages_unsorted', 'source.pages should be sorted ascending.', ['file' => $file]);
}
$pdfPath = $booksRoot . '/' . $pdfName;
if (!is_file($pdfPath)) {
fail('source_pdf_missing', 'Referenced source PDF is not available for verification.', ['file' => $file, 'pdf' => $pdfName, 'expected_path' => $pdfPath]);
return;
}
$pageCount = pdfPageCount($pdfPath);
if ($pageCount === null) {
fail('source_pdf_unreadable', 'Unable to determine page count for source PDF.', ['file' => $file, 'pdf' => $pdfName]);
return;
}
$summary['source_references_checked']++;
foreach ($pages as $page) {
if (!is_int($page) || $page < 1 || $page > $pageCount) {
fail('source_page_out_of_range', 'Referenced page is outside the source PDF.', ['file' => $file, 'pdf' => $pdfName, 'page' => $page, 'page_count' => $pageCount]);
}
}
}
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, $match)) {
return (int) $match[1];
}
}
return null;
}
function lessonContent(string $markdown): string
{
if (!str_contains($markdown, '## محتوى الدرس')) {
return trim(preg_replace('/\A---\r?\n.*?\r?\n---\r?\n/s', '', $markdown) ?? '');
}
$afterHeading = explode('## محتوى الدرس', $markdown, 2)[1];
$beforeNextHeading = preg_split('/\r?\n## /', $afterHeading, 2)[0] ?? '';
return trim($beforeNextHeading);
}
function normalizeBody(string $content): string
{
return preg_replace('/\s+/u', ' ', trim($content)) ?? '';
}
function contentType(string $path): string
{
$name = pathinfo($path, PATHINFO_FILENAME);
return match (true) {
str_starts_with($name, 'lesson_') => 'lesson',
str_contains($name, 'review'), str_contains($name, 'exam'), str_contains($name, 'test') => 'review_or_exam',
str_contains($name, 'lab') => 'lab',
str_contains($name, 'project'), str_contains($name, 'intro') => 'project_or_intro',
str_contains($name, 'summary') => 'summary',
default => 'special_resource',
};
}
function resolveWithin(string $root, string $relativePath): ?string
{
$rootPath = realpath($root);
$candidate = $rootPath ? realpath($rootPath . '/' . ltrim($relativePath, '/')) : false;
$prefix = $rootPath ? rtrim($rootPath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR : '';
return $candidate && $prefix !== '' && str_starts_with($candidate, $prefix) ? $candidate : null;
}
function relativePath(string $path, string $root): string
{
return ltrim(substr($path, strlen(rtrim($root, DIRECTORY_SEPARATOR))), DIRECTORY_SEPARATOR);
}
function fail(string $code, string $message, array $context = []): void
{
global $issues;
$issues[] = ['severity' => 'error', 'code' => $code, 'message' => $message, 'context' => $context];
}
function warn(string $code, string $message, array $context = []): void
{
global $issues;
$issues[] = ['severity' => 'warning', 'code' => $code, 'message' => $message, 'context' => $context];
}
function countIssues(string $code): int
{
global $issues;
return count(array_filter($issues, static fn (array $issue): bool => $issue['code'] === $code));
}
function finish(): never
{
global $issues, $summary, $jsonOutput;
$errors = array_values(array_filter($issues, static fn (array $issue): bool => $issue['severity'] === 'error'));
$warnings = array_values(array_filter($issues, static fn (array $issue): bool => $issue['severity'] === 'warning'));
$result = ['status' => $errors === [] ? 'passed' : 'blocked', 'summary' => $summary + ['errors' => count($errors), 'warnings' => count($warnings)], 'errors' => $errors, 'warnings' => $warnings];
if ($jsonOutput) {
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE) . PHP_EOL;
} else {
echo sprintf("Candidate intake: %s — %d errors, %d warnings\n", $result['status'], count($errors), count($warnings));
foreach ($errors as $issue) {
echo '[ERROR] ' . $issue['code'] . ' — ' . $issue['message'] . PHP_EOL;
}
foreach ($warnings as $issue) {
echo '[WARN] ' . $issue['code'] . ' — ' . $issue['message'] . PHP_EOL;
}
}
exit($errors === [] ? 0 : 1);
}
+115
View File
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
/**
* Read-only acceptance check for the Grade 10 candidate intake work.
*
* Asserts (no database, no network, no file writes):
* 1. The strict intake gate PASSES for manifest.accepted.json (0 errors).
* 2. The strict intake gate still BLOCKS manifest.candidate.json, and every
* remaining blocking error belongs to a quarantined file in review-queue.json.
* 3. Every accepted entry carries an explicit canonical resource_type and a
* source reference; non-lesson files are never typed as video lessons.
* 4. Quarantined files are present on disk, absent from the accepted manifest,
* and listed in review-queue.json.
* 5. The live student manifest (backend/storage/curriculum/manifest.json) is
* unmodified, and the promotion path contains no lessons-table writes and
* no published/approved status.
*
* Usage:
* php backend/scripts/verify_grade10_accepted.php
*/
$projectRoot = dirname(__DIR__, 2);
$failures = [];
function check(bool $condition, string $message): void
{
global $failures;
echo ($condition ? "PASS " : "FAIL ") . $message . PHP_EOL;
if (!$condition) {
$failures[] = $message;
}
}
function runGate(string $projectRoot, string $manifest): array
{
$out = shell_exec(
'php ' . escapeshellarg($projectRoot . '/backend/scripts/validate_grade10_candidate.php')
. ' --json --manifest=' . escapeshellarg($manifest) . ' 2>/dev/null'
);
$decoded = json_decode((string) $out, true);
return is_array($decoded) ? $decoded : [];
}
$accepted = runGate($projectRoot, 'manifest.accepted.json');
check(($accepted['status'] ?? null) === 'passed', 'gate passes for manifest.accepted.json');
check(count($accepted['errors'] ?? [null]) === 0, 'zero blocking errors in accepted package (369 entries)');
$candidate = runGate($projectRoot, 'manifest.candidate.json');
check(($candidate['status'] ?? null) === 'blocked', 'gate still blocks the full candidate set until humans resolve quarantine');
$queue = json_decode((string) file_get_contents(
$projectRoot . '/backend/storage/curriculum/_incoming/grade_10/review-queue.json'
), true);
$quarantined = array_column($queue['quarantined'] ?? [], 'file');
sort($quarantined);
check(count($quarantined) === 3, 'review-queue lists exactly 3 quarantined files');
$stray = [];
foreach ($candidate['errors'] ?? [] as $error) {
$ctx = json_encode($error['context'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$belongs = false;
foreach ($quarantined as $q) {
if (str_contains($ctx, $q)) {
$belongs = true;
break;
}
}
if (!$belongs && ($error['code'] ?? '') !== 'qa_claim_conflict') {
$stray[] = $error;
}
}
check($stray === [], 'every remaining blocking error traces to a quarantined file');
$acceptedManifest = json_decode((string) file_get_contents(
$projectRoot . '/backend/storage/curriculum/_incoming/grade_10/manifest.accepted.json'
), true);
$acceptedFiles = array_column($acceptedManifest['lessons'] ?? [], 'file');
check(count($acceptedFiles) === 369, 'accepted manifest holds 369 entries');
check(count(array_intersect($acceptedFiles, $quarantined)) === 0, 'quarantined files are absent from the accepted manifest');
$typeViolations = 0;
foreach ($accepted['summary']['resource_types'] ?? [] as $type => $count) {
if (!in_array($type, ['lesson', 'unit_review', 'worksheet', 'lab', 'project', 'special_resource'], true)) {
$typeViolations++;
}
}
check($typeViolations === 0, 'all resource_type values use the canonical vocabulary');
check(($accepted['summary']['resource_types']['lesson'] ?? 0) === 302, '302 lesson files; reviews/labs/projects are not video lessons');
foreach ($quarantined as $q) {
check(is_file($projectRoot . '/backend/storage/curriculum/_incoming/' . $q), 'quarantined file preserved on disk: ' . $q);
}
$liveManifestStatus = shell_exec('git -C ' . escapeshellarg($projectRoot) . ' status --porcelain -- backend/storage/curriculum/manifest.json 2>/dev/null');
check(trim((string) $liveManifestStatus) === '', 'live student manifest.json untouched');
$promote = (string) file_get_contents($projectRoot . '/backend/scripts/promote_grade10_candidate.php');
check(!preg_match('/\bINTO\s+lessons\b|\bUPDATE\s+lessons\b/i', $promote), 'promotion path never writes the lessons table');
$sqlLines = array_filter(
explode("\n", $promote),
static fn (string $line): bool => (bool) preg_match('/\b(UPDATE|INSERT|DELETE|SET)\b/i', $line)
);
$sqlText = implode("\n", $sqlLines);
check(!str_contains($sqlText, 'markdown_content'), 'promotion path never writes lessons.markdown_content');
check(str_contains($promote, "'draft'"), 'promotion bundles/assets are draft-only');
check(!preg_match("/status['\"]?\s*(=>|,)\s*['\"]published['\"]/i", $promote), 'promotion path contains no published status');
check(!preg_match("/review_status.*approved|['\"]approved['\"]/i", $promote), 'promotion path contains no approval');
if ($failures !== []) {
fwrite(STDERR, count($failures) . " acceptance check(s) failed.\n");
exit(1);
}
echo "All acceptance checks passed.\n";