"""Deterministic Kerala SSLC Physics textbook ingestion. The official PDF is the only source of educational text in the generated artifacts. Native extraction is attempted first. Pages with too little text are reported for selective OCR instead of silently fabricating content. """ from __future__ import annotations import hashlib import json import os import re from dataclasses import dataclass from pathlib import Path from typing import Any, Iterable from pypdf import PdfReader PROJECT_ROOT = Path(__file__).resolve().parents[2] OUTPUT_ROOT = PROJECT_ROOT / "data" / "curriculum" / "kerala-sslc" / "physics" EXTRACTION_VERSION = "docdoe-textbook-v1.1" DISCOVERY_HINTS = ( "sslc", "standard-10", "standard 10", "class-10", "class 10", "physics", "scert", "textbook", ) SKIP_DIRS = {".git", ".next", "node_modules", ".venv", "generated-videos", "uploads"} @dataclass(frozen=True) class ChapterCandidate: number: str title: str start_page: int def sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def stable_hash(*parts: object, length: int = 20) -> str: raw = "|".join(str(part) for part in parts) return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:length] def normalized_text(value: str) -> str: return re.sub(r"\s+", " ", value).strip() def source_roots() -> list[Path]: roots = [PROJECT_ROOT] configured = os.getenv("TEXTBOOK_SOURCE_DIRS", "") for raw in configured.split(os.pathsep): if raw.strip(): roots.append(Path(raw.strip()).expanduser().resolve()) return list(dict.fromkeys(roots)) def discover_pdf_candidates(roots: Iterable[Path] | None = None) -> list[Path]: candidates: list[Path] = [] for root in roots or source_roots(): if not root.exists(): continue for path in root.rglob("*.pdf"): if any(part.lower() in SKIP_DIRS for part in path.parts): continue hint = str(path).lower().replace("_", " ") if any(term in hint for term in DISCOVERY_HINTS): candidates.append(path.resolve()) return sorted(set(candidates)) def read_native_pages(path: Path) -> tuple[list[str], dict[str, Any]]: reader = PdfReader(str(path)) metadata = {str(key).lstrip("/"): str(value) for key, value in (reader.metadata or {}).items()} pages = [(page.extract_text() or "").replace("\x00", "") for page in reader.pages] return pages, metadata def read_native_opening(path: Path, count: int = 6) -> list[str]: reader = PdfReader(str(path)) return [ (reader.pages[index].extract_text() or "").replace("\x00", "") for index in range(min(count, len(reader.pages))) ] def is_kerala_sslc_physics(pages: list[str]) -> bool: opening = normalized_text("\n".join(pages[:6])).lower() return ( "physics" in opening and "state council of educational research and training" in opening and ("standard" in opening or "sslc" in opening) and "government of kerala" in opening ) def detect_part(pages: list[str]) -> str | None: cover = normalized_text("\n".join(pages[:3])) match = re.search(r"\bPart\s+([12])\b", cover, flags=re.IGNORECASE) return f"Part {match.group(1)}" if match else None def parse_contents_chapters(page_text: str) -> list[ChapterCandidate]: candidates: list[ChapterCandidate] = [] for raw_line in page_text.splitlines(): line = normalized_text(raw_line) match = re.match(r"^(\d{1,2})\s+(.+?)(?:[-.\s]{3,})\s*(\d{1,3})$", line) if not match: match = re.match(r"^(\d{1,2})\s+([A-Za-z][A-Za-z :,&-]{4,}?)\s+(\d{1,3})$", line) if not match: continue number, title, start = match.groups() title = title.strip(" -.\t") if title and int(start) > 0: candidates.append(ChapterCandidate(number=number, title=title, start_page=int(start))) return candidates def detect_chapters(pages: list[str]) -> tuple[list[ChapterCandidate], list[str]]: warnings: list[str] = [] candidates: list[ChapterCandidate] = [] for page in pages[:12]: if "contents" in page.lower(): candidates.extend(parse_contents_chapters(page)) unique: dict[tuple[str, int], ChapterCandidate] = {} for candidate in candidates: unique[(candidate.number, candidate.start_page)] = candidate ordered = sorted(unique.values(), key=lambda item: item.start_page) if not ordered: warnings.append("No chapter table-of-contents entries were detected.") for previous, current in zip(ordered, ordered[1:]): if current.start_page <= previous.start_page: warnings.append( f"Chapter boundary is not increasing: {previous.title} -> {current.title}.", ) return ordered, warnings def find_chapter_pdf_page(pages: list[str], title: str) -> int | None: needle = normalized_text(title).lower() needle_tokens = set(re.findall(r"[a-z]+", needle)) for index, page in enumerate(pages[5:], start=6): haystack = normalized_text(page).lower() haystack_tokens = set(re.findall(r"[a-z]+", haystack[:1400])) overlap = len(needle_tokens & haystack_tokens) / max(1, len(needle_tokens)) if needle in haystack or overlap >= 0.8: return index return None def classify_block(text: str) -> str: value = normalized_text(text) lower = value.lower() if re.match(r"^fig\.?\s*\d", lower): return "caption" if "let's assess" in lower or value.endswith("?"): return "question" if lower.startswith(("let's do", "activity", "experiment")): return "activity" if re.search(r"\b(is called|is known as|refers to|are those|is the)\b", lower): return "definition" if "=" in value or re.search(r"[λ∝×÷]\s*", value): return "formula" if lower.startswith(("summary", "remember", "in brief")): return "summary" if len(value) <= 90 and not value.endswith((".", "?", "!")): return "heading" return "paragraph" def page_blocks( source_id: str, chapter_id: str, page_number: int, pdf_page_number: int, page_text: str, ) -> list[dict[str, Any]]: raw_blocks = [normalized_text(part) for part in re.split(r"\n\s*\n|\n(?=[A-Z•])", page_text)] blocks: list[dict[str, Any]] = [] for index, raw in enumerate(block for block in raw_blocks if block): block_type = classify_block(raw) source_hash = stable_hash(source_id, page_number, block_type, raw, length=64) blocks.append( { "blockId": f"blk-{stable_hash(chapter_id, page_number, index, raw)}", "chapterId": chapter_id, "pageNumber": page_number, "pdfPageNumber": pdf_page_number, "blockType": block_type, "rawText": raw, "normalizedText": normalized_text(raw), "confidence": 0.92 if len(raw) >= 20 else 0.72, "sourceHash": source_hash, } ) return blocks def figures_from_page( source_id: str, chapter_id: str, page_number: int, pdf_page_number: int, page_text: str, ) -> list[dict[str, Any]]: figures: list[dict[str, Any]] = [] for index, match in enumerate(re.finditer(r"Fig\.?\s*(\d+\.\d+)(?:\s*\(([a-z])\))?", page_text, re.IGNORECASE)): label = f"Fig. {match.group(1)}{f' ({match.group(2)})' if match.group(2) else ''}" figures.append( { "figureId": f"fig-{stable_hash(chapter_id, page_number, label, index)}", "chapterId": chapter_id, "pageNumber": page_number, "pdfPageNumber": pdf_page_number, "caption": label, "sourceHash": stable_hash(source_id, page_number, label, length=64), "confidence": 0.82, "requiresRegionRender": True, } ) return figures def build_source(path: Path) -> tuple[dict[str, Any], list[str], dict[str, Any]]: pages, metadata = read_native_pages(path) if not is_kerala_sslc_physics(pages): raise ValueError(f"Not a verified Kerala SSLC Physics textbook: {path}") file_hash = sha256_file(path) part = detect_part(pages) part_slug = (part or "part-unknown").lower().replace(" ", "-") source_id = f"scert-sslc-physics-{part_slug}-{file_hash[:12]}" source = { "sourceId": source_id, "filePath": str(path.relative_to(PROJECT_ROOT)).replace("\\", "/") if path.is_relative_to(PROJECT_ROOT) else str(path), "fileName": path.name, "sha256": file_hash, "board": "Kerala SCERT", "classLevel": "SSLC", "subject": "Physics", "medium": "English", "part": part, "pageCount": len(pages), "extractionVersion": EXTRACTION_VERSION, "metadata": metadata, } return source, pages, metadata def ingest_textbook(path: Path) -> tuple[dict[str, Any], dict[str, Any]]: source, pages, _metadata = build_source(path) chapter_candidates, warnings = detect_chapters(pages) physical_starts = [find_chapter_pdf_page(pages, candidate.title) for candidate in chapter_candidates] chapters: list[dict[str, Any]] = [] assigned_pages: set[int] = set() for index, candidate in enumerate(chapter_candidates): physical_start = physical_starts[index] if physical_start is None: warnings.append(f"Could not locate the physical PDF start for chapter: {candidate.title}.") continue next_physical = physical_starts[index + 1] if index + 1 < len(physical_starts) else None physical_end = next_physical - 1 if next_physical is not None else len(pages) page_offset = candidate.start_page - physical_start end_page = physical_end + page_offset part_number = re.search(r"\d+", source.get("part") or "") chapter_id = f"phy-p{part_number.group(0) if part_number else 'x'}-c{candidate.number}" blocks: list[dict[str, Any]] = [] figures: list[dict[str, Any]] = [] low_text_pages: list[int] = [] for pdf_page_number in range(physical_start, physical_end + 1): assigned_pages.add(pdf_page_number) page_number = pdf_page_number + page_offset page_text = pages[pdf_page_number - 1] if len(normalized_text(page_text)) < 80: low_text_pages.append(page_number) blocks.extend(page_blocks(source["sourceId"], chapter_id, page_number, pdf_page_number, page_text)) figures.extend(figures_from_page(source["sourceId"], chapter_id, page_number, pdf_page_number, page_text)) headings = [block for block in blocks if block["blockType"] == "heading"] chapter_warnings = [f"Page {page} needs selective OCR review." for page in low_text_pages] chapters.append( { "chapterId": chapter_id, "sourceId": source["sourceId"], "chapterNumber": candidate.number, "title": candidate.title, "startPage": candidate.start_page, "endPage": end_page, "pdfStartPage": physical_start, "pdfEndPage": physical_end, "learningOutcomes": [], "sections": headings, "exercises": [block for block in blocks if block["blockType"] == "question"], "figures": figures, "blocks": blocks, "extractionWarnings": chapter_warnings, } ) unassigned = [page for page in range(1, len(pages) + 1) if page not in assigned_pages] extracted = { "schemaVersion": 1, "source": source, "pages": [ { "pdfPageNumber": index + 1, "text": text, "nativeTextLength": len(normalized_text(text)), "needsOcr": len(normalized_text(text)) < 80, } for index, text in enumerate(pages) ], "chapters": chapters, "warnings": warnings, } report = { "sourceId": source["sourceId"], "textbooksDiscovered": 1, "chaptersDiscovered": len(chapters), "chapterPages": [ { "chapterId": chapter["chapterId"], "printedStart": chapter["startPage"], "printedEnd": chapter["endPage"], "pdfStart": chapter["pdfStartPage"], "pdfEnd": chapter["pdfEndPage"], } for chapter in chapters ], "unassignedPages": unassigned, "duplicateChapterCandidates": [], "lowConfidenceSections": [ block["blockId"] for chapter in chapters for block in chapter["blocks"] if block["confidence"] < 0.8 ], "equationExtractionWarnings": [ f"Review formula block {block['blockId']} on page {block['pageNumber']}" for chapter in chapters for block in chapter["blocks"] if block["blockType"] == "formula" and len(block["normalizedText"]) > 220 ], "warnings": warnings, } return extracted, report def write_json_if_changed(path: Path, payload: Any) -> bool: rendered = json.dumps(payload, ensure_ascii=False, indent=2) + "\n" if path.exists() and path.read_text(encoding="utf-8") == rendered: return False path.parent.mkdir(parents=True, exist_ok=True) path.write_text(rendered, encoding="utf-8") return True def build_manifest(extractions: list[dict[str, Any]]) -> dict[str, Any]: sources = [item["source"] for item in extractions] chapters: list[dict[str, Any]] = [] for item in extractions: for chapter in item["chapters"]: blocks = chapter["blocks"] chapters.append( { "chapterId": chapter["chapterId"], "chapterNumber": chapter["chapterNumber"], "title": chapter["title"], "sourceId": chapter["sourceId"], "sourcePages": {"start": chapter["startPage"], "end": chapter["endPage"]}, "pdfPages": {"start": chapter["pdfStartPage"], "end": chapter["pdfEndPage"]}, "sections": [block["normalizedText"] for block in chapter["sections"]], "learningOutcomes": [], "figures": chapter["figures"], "experiments": [block for block in blocks if block["blockType"] in {"activity", "experiment"}], "formulas": [block for block in blocks if block["blockType"] == "formula"], "workedExamples": [block for block in blocks if block["blockType"] == "example"], "exerciseQuestionCount": len(chapter["exercises"]), "extractionWarnings": chapter["extractionWarnings"], } ) return { "schemaVersion": 1, "board": "Kerala SCERT", "classLevel": "SSLC", "subject": "Physics", "medium": "English", "textbookSources": sources, "chapters": chapters, } def main() -> int: verified_by_hash: dict[str, Path] = {} rejected: list[str] = [] for candidate in discover_pdf_candidates(): try: pages = read_native_opening(candidate) if is_kerala_sslc_physics(pages): verified_by_hash.setdefault(sha256_file(candidate), candidate) except Exception as exc: # noqa: BLE001 rejected.append(f"{candidate}: {exc}") verified = sorted(verified_by_hash.values()) if not verified: raise SystemExit("No verified Kerala SSLC Physics textbook PDFs were discovered.") extractions: list[dict[str, Any]] = [] reports: list[dict[str, Any]] = [] changed = 0 for path in verified: extraction, report = ingest_textbook(path) extractions.append(extraction) reports.append(report) cache_path = OUTPUT_ROOT / "textbooks" / f"{extraction['source']['sha256']}.json" changed += int(write_json_if_changed(cache_path, extraction)) changed += int(write_json_if_changed(OUTPUT_ROOT / "chapter-manifest.json", build_manifest(extractions))) changed += int( write_json_if_changed( OUTPUT_ROOT / "ingestion-report.json", { "schemaVersion": 1, "verifiedTextbooks": [str(path) for path in verified], "rejectedCandidates": rejected, "reports": reports, }, ) ) print( json.dumps( { "verifiedTextbooks": len(verified), "chapters": sum(len(item["chapters"]) for item in extractions), "filesChanged": changed, "output": str(OUTPUT_ROOT), }, indent=2, ) ) return 0 if __name__ == "__main__": raise SystemExit(main())