USF00 commited on
Commit
d473af5
·
0 Parent(s):

Initial deployment setup for Summarization_Deploy

Browse files
Summarization_Feature.ipynb ADDED
The diff for this file is too large to render. See raw diff
 
hf_space_deployment/.dockerignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .env
4
+ output/
5
+ tmp/
6
+ *.zip
7
+ .ipynb_checkpoints/
8
+ .git/
9
+ .gitignore
10
+ README.md
hf_space_deployment/.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ * text=auto
hf_space_deployment/.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .env
4
+ output/
5
+ tmp/
6
+ *.zip
7
+ .ipynb_checkpoints/
hf_space_deployment/Dockerfile ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+ ENV HF_HOME=/tmp/huggingface
3
+
4
+ # Install system dependencies
5
+ # HF spaces runs on Debian-based images
6
+ RUN apt-get update && apt-get install -y \
7
+ poppler-utils \
8
+ tesseract-ocr \
9
+ tesseract-ocr-eng \
10
+ tesseract-ocr-ara \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ # Set up a new user named "user" with user ID 1000
14
+ RUN useradd -m -u 1000 user
15
+ USER user
16
+ ENV PATH="/home/user/.local/bin:$PATH"
17
+
18
+ # Set working directory
19
+ WORKDIR /app
20
+
21
+ # Copy requirements and install
22
+ COPY --chown=user requirements.txt .
23
+ RUN pip install --no-cache-dir --upgrade pip && \
24
+ pip install --no-cache-dir -r requirements.txt
25
+
26
+ # Copy project files
27
+ COPY --chown=user . .
28
+
29
+ # Expose port 7860 for Hugging Face Spaces
30
+ EXPOSE 7860
31
+
32
+ ENV PYTHONUNBUFFERED=1
33
+
34
+ # Run uvicorn
35
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
hf_space_deployment/README.md ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: LITVISION Summarization API
3
+ emoji: 📚
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: docker
7
+ pinned: false
8
+ license: mit
9
+ ---
10
+
11
+ # LITVISION Book Summarization API
12
+
13
+ A production-ready FastAPI endpoint for the LITVISION Book Summarization Feature. This service accepts PDF or TXT files, extracts text (using native extraction with OCR fallback for scanned pages), chunks the text smartly, and generates both per-chapter summaries and a final organized summary using `facebook/bart-large-cnn`.
14
+
15
+ It is fully configured for deployment on Hugging Face Spaces (Docker).
16
+
17
+ ## Features
18
+
19
+ - **Text Extraction:** Native PDF text extraction using `PyMuPDF`.
20
+ - **OCR Fallback:** Scans unextractable PDF pages using `pytesseract` (supports English and Arabic).
21
+ - **Smart Chunking:** Token-aware sentence grouping to prevent cutting mid-sentence.
22
+ - **Generative AI:** Uses `BART-large-CNN` on GPU (or CPU fallback) with FP16 optimization.
23
+ - **FastAPI Backend:** Fully async HTTP endpoint for file uploads.
24
+ - **Hugging Face Ready:** Pre-configured `Dockerfile` with non-root user and correct port mappings.
25
+
26
+ ## API Endpoints
27
+
28
+ ### `GET /`
29
+ Returns basic API information.
30
+
31
+ ### `GET /health`
32
+ Returns health status.
33
+ ```json
34
+ {
35
+ "status": "healthy",
36
+ "model_loaded": true,
37
+ "device": "cuda"
38
+ }
39
+ ```
40
+
41
+ ### `POST /summarize`
42
+ Accepts a PDF or TXT file via `multipart/form-data`.
43
+
44
+ **Request:**
45
+ ```bash
46
+ curl -X POST -F "file=@book.pdf" http://localhost:7860/summarize
47
+ ```
48
+
49
+ **Response Format:**
50
+ ```json
51
+ {
52
+ "success": true,
53
+ "file_name": "book.pdf",
54
+ "num_chapters": 1,
55
+ "chapter_summaries": [
56
+ {
57
+ "chapter": "BOOK",
58
+ "summary": "..."
59
+ }
60
+ ],
61
+ "final_summary": "..."
62
+ }
63
+ ```
64
+
65
+ ## Folder Structure
66
+
67
+ ```
68
+ .
69
+ ├── app.py # FastAPI endpoints and startup events
70
+ ├── summarizer.py # AI generation logic (BART model)
71
+ ├── utils.py # PDF extraction, OCR, and chunking tools
72
+ ├── requirements.txt # Python dependencies
73
+ ├── Dockerfile # Container configuration
74
+ ├── .dockerignore
75
+ ├── .gitignore
76
+ └── README.md
77
+ ```
78
+
79
+ ## Local Development
80
+
81
+ ### 1. Install System Dependencies (Linux/macOS)
82
+ Make sure you have Tesseract and Poppler installed:
83
+ - **Ubuntu:** `sudo apt-get install poppler-utils tesseract-ocr tesseract-ocr-eng tesseract-ocr-ara`
84
+ - **Mac:** `brew install poppler tesseract tesseract-lang`
85
+
86
+ ### 2. Install Python Dependencies
87
+ ```bash
88
+ pip install -r requirements.txt
89
+ ```
90
+
91
+ ### 3. Run the Server
92
+ ```bash
93
+ uvicorn app:app --host 0.0.0.0 --port 7860 --reload
94
+ ```
95
+
96
+ ## Docker Build & Run (Local)
97
+
98
+ ```bash
99
+ docker build -t litvision-summarizer .
100
+ docker run -p 7860:7860 --gpus all litvision-summarizer
101
+ ```
102
+ *(Remove `--gpus all` if running on CPU)*
103
+
104
+ ## Deployment to Hugging Face Spaces
105
+
106
+ 1. Go to Hugging Face and create a new Space.
107
+ 2. Select **Docker** as the Space SDK.
108
+ 3. Upload all the files in this directory directly to the repository.
109
+ 4. The space will automatically build the container and start the Uvicorn server on port 7860.
110
+
111
+ ## Troubleshooting
112
+
113
+ - **CUDA OOM Errors:** Ensure the uploaded book is not excessively long, or adjust the `BATCH_SIZE` in `summarizer.py`.
114
+ - **OCR Not Working:** Verify Tesseract language packs (`tesseract-ocr-ara` and `tesseract-ocr-eng`) are correctly installed in your environment.
hf_space_deployment/app.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import tempfile
3
+ import logging
4
+ import asyncio
5
+ import torch
6
+ from fastapi import FastAPI, File, UploadFile, HTTPException, BackgroundTasks
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from fastapi.responses import JSONResponse
9
+ from pydantic import BaseModel
10
+ from typing import List
11
+
12
+ from utils import extract_text_from_file, split_into_chapters
13
+ from summarizer import summarizer, CHAPTER_MIN_NEW_TOKENS_FLOOR, CHAPTER_MAX_NEW_TOKENS_CAP
14
+
15
+ logging.basicConfig(level=logging.INFO)
16
+ logger = logging.getLogger(__name__)
17
+
18
+ app = FastAPI(
19
+ title="LITVISION Book Summarization API",
20
+ description="Extracts text from PDFs/TXTs, chunks, and generates chapter/final summaries.",
21
+ version="1.0.0"
22
+ )
23
+
24
+ app.add_middleware(
25
+ CORSMiddleware,
26
+ allow_origins=["*"],
27
+ allow_credentials=True,
28
+ allow_methods=["*"],
29
+ allow_headers=["*"],
30
+ )
31
+
32
+
33
+
34
+ @app.get("/")
35
+ async def root():
36
+ return {
37
+ "api": "LITVISION Book Summarization API",
38
+ "status": "online",
39
+ "endpoints": ["/health", "/summarize"]
40
+ }
41
+
42
+ @app.get("/health")
43
+ async def health():
44
+ return {
45
+ "status": "healthy",
46
+ "model_loaded": summarizer.model is not None,
47
+ "device": summarizer.device
48
+ }
49
+
50
+ class ChapterSummary(BaseModel):
51
+ chapter: str
52
+ summary: str
53
+
54
+ class SummarizationResponse(BaseModel):
55
+ success: bool
56
+ file_name: str
57
+ num_chapters: int
58
+ chapter_summaries: List[ChapterSummary]
59
+ final_summary: str
60
+
61
+ def remove_temp_file(path: str):
62
+ try:
63
+ if os.path.exists(path):
64
+ os.remove(path)
65
+ logger.info(f"Deleted temp file: {path}")
66
+ except Exception as e:
67
+ logger.error(f"Error deleting temp file {path}: {e}")
68
+
69
+ @app.post("/summarize", response_model=SummarizationResponse)
70
+ async def summarize_endpoint(background_tasks: BackgroundTasks, file: UploadFile = File(...)):
71
+ if not file.filename.lower().endswith(('.pdf', '.txt')):
72
+ raise HTTPException(status_code=400, detail="Invalid file type. Only .pdf and .txt are supported.")
73
+
74
+ temp_file_path = ""
75
+ try:
76
+ suffix = os.path.splitext(file.filename)[1]
77
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
78
+ content = await file.read()
79
+ if not content:
80
+ raise HTTPException(status_code=400, detail="Empty file provided.")
81
+ if len(content) > 50 * 1024 * 1024:
82
+ raise HTTPException(status_code=413, detail="File too large. Max size is 50MB.")
83
+ temp_file.write(content)
84
+ temp_file_path = temp_file.name
85
+
86
+ logger.info(f"Extracting text from {file.filename}...")
87
+ text = await asyncio.to_thread(extract_text_from_file, temp_file_path)
88
+
89
+ if not text.strip():
90
+ raise HTTPException(status_code=422, detail="Could not extract any text from the file.")
91
+
92
+ logger.info("Splitting into chapters...")
93
+ chapters = split_into_chapters(text)
94
+
95
+ chapter_summaries_result = []
96
+ raw_chapter_summaries = []
97
+
98
+ logger.info(f"Generating summaries for {len(chapters)} chapters...")
99
+ for title, body in chapters:
100
+ if not body.strip():
101
+ continue
102
+
103
+ summ = await asyncio.to_thread(
104
+ summarizer.summarize_long_text,
105
+ body,
106
+ CHAPTER_MIN_NEW_TOKENS_FLOOR,
107
+ CHAPTER_MAX_NEW_TOKENS_CAP
108
+ )
109
+ raw_chapter_summaries.append(summ)
110
+ chapter_summaries_result.append(ChapterSummary(chapter=title, summary=summ))
111
+
112
+ logger.info("Generating final organized summary...")
113
+ final_summary = await asyncio.to_thread(summarizer.make_big_book_summary, raw_chapter_summaries)
114
+
115
+ return SummarizationResponse(
116
+ success=True,
117
+ file_name=file.filename,
118
+ num_chapters=len(chapter_summaries_result),
119
+ chapter_summaries=chapter_summaries_result,
120
+ final_summary=final_summary
121
+ )
122
+
123
+ except Exception as e:
124
+ logger.error(f"Error during summarization: {e}")
125
+ error_msg = str(e).lower()
126
+ if isinstance(e, torch.cuda.OutOfMemoryError) or "out of memory" in error_msg:
127
+ if torch.cuda.is_available():
128
+ torch.cuda.empty_cache()
129
+ raise HTTPException(status_code=500, detail="CUDA out of memory. Try a smaller file.")
130
+ raise HTTPException(status_code=500, detail=str(e))
131
+ finally:
132
+ if temp_file_path:
133
+ background_tasks.add_task(remove_temp_file, temp_file_path)
hf_space_deployment/requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.100.0
2
+ uvicorn>=0.23.0
3
+ python-multipart>=0.0.6
4
+ transformers>=4.33.0
5
+ torch>=2.0.0
6
+ pymupdf>=1.23.0
7
+ pdf2image>=1.16.3
8
+ pytesseract>=0.3.10
9
+ Pillow>=10.0.0
10
+ tqdm>=4.66.0
11
+ sentencepiece>=0.1.99
12
+ accelerate>=0.23.0
hf_space_deployment/summarizer.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import logging
3
+ import torch
4
+ from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
5
+ from utils import iter_paragraphs, split_sentences, normalize_text
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ # Model config
10
+ MODEL_NAME = "facebook/bart-large-cnn"
11
+ BATCH_SIZE = 4
12
+ NUM_BEAMS = 4
13
+ NO_REPEAT_NGRAM_SIZE = 3
14
+ EARLY_STOPPING = False
15
+
16
+ # Chunking config
17
+ MAX_INPUT_TOKENS = 1024
18
+ HEADROOM_TOKENS = 16
19
+ EFFECTIVE_MAX_INPUT = MAX_INPUT_TOKENS - HEADROOM_TOKENS
20
+ OVERLAP_SENTENCES = 2
21
+
22
+ # Output size caps
23
+ CHAPTER_MAX_NEW_TOKENS_CAP = 320
24
+ CHAPTER_MIN_NEW_TOKENS_FLOOR = 120
25
+ BOOK_PARTS = 8
26
+
27
+ class BookSummarizer:
28
+ def __init__(self):
29
+ self.device = "cuda" if torch.cuda.is_available() else "cpu"
30
+ self.tokenizer = None
31
+ self.model = None
32
+
33
+ def load_model(self):
34
+ """Loads the tokenizer and model into memory."""
35
+ if self.model is not None:
36
+ return
37
+
38
+ logger.info(f"Loading model {MODEL_NAME} onto {self.device}...")
39
+ self.tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, use_fast=True)
40
+ self.model = AutoModelForSeq2SeqLM.from_pretrained(MODEL_NAME).to(self.device)
41
+
42
+ if self.device == "cuda":
43
+ try:
44
+ self.model.half()
45
+ except Exception as e:
46
+ logger.warning(f"Could not convert model to fp16: {e}")
47
+
48
+ self.model.eval()
49
+ logger.info("Model loaded successfully.")
50
+
51
+ def tok_len(self, s: str) -> int:
52
+ return len(self.tokenizer.encode(s, add_special_tokens=False))
53
+
54
+ def split_by_tokens(self, s: str, max_len: int, overlap_tokens: int = 64):
55
+ ids = self.tokenizer.encode(s, add_special_tokens=False)
56
+ if len(ids) <= max_len:
57
+ return [s.strip()]
58
+ overlap_tokens = max(0, min(overlap_tokens, max_len // 3))
59
+ step = max(1, max_len - overlap_tokens)
60
+ parts = []
61
+ for i in range(0, len(ids), step):
62
+ chunk_ids = ids[i:i+max_len]
63
+ if not chunk_ids:
64
+ continue
65
+ t = self.tokenizer.decode(chunk_ids, skip_special_tokens=True, clean_up_tokenization_spaces=True).strip()
66
+ if t:
67
+ parts.append(t)
68
+ return parts
69
+
70
+ def chunk_text(self, text: str, max_input_tokens: int = EFFECTIVE_MAX_INPUT, overlap_sentences: int = OVERLAP_SENTENCES):
71
+ text = normalize_text(text)
72
+ if not text:
73
+ return []
74
+
75
+ chunks = []
76
+ cur_sents, cur_tok = [], 0
77
+
78
+ def flush():
79
+ nonlocal cur_sents, cur_tok
80
+ if cur_sents:
81
+ ch = " ".join(cur_sents).strip()
82
+ if ch:
83
+ chunks.append(ch)
84
+ cur_sents, cur_tok = [], 0
85
+
86
+ for para in iter_paragraphs(text):
87
+ for sent in split_sentences(para):
88
+ st = sent.strip()
89
+ if not st:
90
+ continue
91
+ st_tok = self.tok_len(st)
92
+
93
+ if st_tok > max_input_tokens:
94
+ flush()
95
+ chunks.extend(self.split_by_tokens(st, max_len=max_input_tokens, overlap_tokens=64))
96
+ continue
97
+
98
+ if cur_tok + st_tok <= max_input_tokens:
99
+ cur_sents.append(st)
100
+ cur_tok += st_tok
101
+ else:
102
+ prev = cur_sents[:]
103
+ flush()
104
+ overlap = prev[-overlap_sentences:] if overlap_sentences and prev else []
105
+ cur_sents = overlap + [st]
106
+ cur_tok = self.tok_len(" ".join(cur_sents))
107
+
108
+ flush()
109
+ return chunks
110
+
111
+ @torch.no_grad()
112
+ def generate_summaries(self, texts, min_new_tokens, max_new_tokens, batch_size=BATCH_SIZE):
113
+ if not self.model:
114
+ self.load_model()
115
+
116
+ outs = []
117
+ for i in range(0, len(texts), batch_size):
118
+ batch = texts[i:i+batch_size]
119
+ enc = self.tokenizer(
120
+ batch, return_tensors="pt",
121
+ truncation=True, padding=True,
122
+ max_length=EFFECTIVE_MAX_INPUT
123
+ ).to(self.device)
124
+
125
+ try:
126
+ gen = self.model.generate(
127
+ **enc,
128
+ num_beams=NUM_BEAMS,
129
+ no_repeat_ngram_size=NO_REPEAT_NGRAM_SIZE,
130
+ min_new_tokens=min_new_tokens,
131
+ max_new_tokens=max_new_tokens,
132
+ early_stopping=EARLY_STOPPING,
133
+ )
134
+ except TypeError:
135
+ gen = self.model.generate(
136
+ **enc,
137
+ num_beams=NUM_BEAMS,
138
+ no_repeat_ngram_size=NO_REPEAT_NGRAM_SIZE,
139
+ min_length=min_new_tokens,
140
+ max_length=max_new_tokens,
141
+ early_stopping=EARLY_STOPPING,
142
+ )
143
+
144
+ decoded = self.tokenizer.batch_decode(gen, skip_special_tokens=True, clean_up_tokenization_spaces=True)
145
+ outs.extend([d.strip() for d in decoded])
146
+ return outs
147
+
148
+ def summarize_long_text(self, text: str, min_new: int, max_new: int):
149
+ chunks = self.chunk_text(text)
150
+ if not chunks:
151
+ return ""
152
+
153
+ chunk_summaries = []
154
+ for ch in chunks:
155
+ tlen = self.tok_len(ch)
156
+ dyn_max = int(min(max_new, max(min_new, round(tlen * 0.18))))
157
+ dyn_min = max(30, min(min_new, dyn_max - 10))
158
+ chunk_summaries.append(self.generate_summaries([ch], dyn_min, dyn_max, batch_size=1)[0])
159
+
160
+ if len(chunk_summaries) == 1:
161
+ return chunk_summaries[0]
162
+
163
+ current = chunk_summaries
164
+ for _ in range(6):
165
+ combined = "\n".join([f"Part {i+1}: {t}" for i, t in enumerate(current)])
166
+ if self.tok_len(combined) <= EFFECTIVE_MAX_INPUT:
167
+ return self.generate_summaries([combined], min_new, max_new, batch_size=1)[0]
168
+
169
+ sub_chunks = self.chunk_text(combined, overlap_sentences=1)
170
+ current = self.generate_summaries(
171
+ sub_chunks,
172
+ min_new_tokens=max(60, min_new // 2),
173
+ max_new_tokens=max(180, max_new // 2),
174
+ batch_size=BATCH_SIZE
175
+ )
176
+ return "\n".join(current).strip()
177
+
178
+ def make_big_book_summary(self, chapter_summaries, parts=BOOK_PARTS):
179
+ chap_summaries = [s for s in chapter_summaries if s.strip()]
180
+ if not chap_summaries:
181
+ return ""
182
+
183
+ n = len(chap_summaries)
184
+ group_size = max(1, math.ceil(n / parts))
185
+ groups = [chap_summaries[i:i+group_size] for i in range(0, n, group_size)]
186
+
187
+ part_summaries = []
188
+ for gi, g in enumerate(groups):
189
+ combined = "\n".join([f"ChapterSummary {gi+1}.{i+1}: {t}" for i, t in enumerate(g)])
190
+ ps = self.summarize_long_text(combined, min_new=220, max_new=520)
191
+ part_summaries.append(ps.strip())
192
+ return "\n\n".join(part_summaries)
193
+
194
+ summarizer = BookSummarizer()
hf_space_deployment/utils.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ from pathlib import Path
4
+ from typing import List, Tuple
5
+
6
+ import fitz # pymupdf
7
+ from pdf2image import convert_from_path
8
+ import pytesseract
9
+ from PIL import ImageOps, ImageEnhance
10
+
11
+ OCR_LANG = "eng+ara"
12
+ OCR_DPI = 180
13
+ NATIVE_MIN_CHARS_PER_PAGE = 60 # if native extracted text < this => OCR that page
14
+
15
+ _SENT_BOUNDARY_RE = re.compile(r"(?<=[\.\!\?\u061F\u06D4\u061B…])\s+") # . ! ? ؟ ۔ ؛ …
16
+
17
+ def normalize_text(text: str) -> str:
18
+ """Normalizes text by removing excessive whitespace and fixing newlines."""
19
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
20
+ text = re.sub(r"[ \t]+", " ", text)
21
+ text = re.sub(r"\n{3,}", "\n\n", text)
22
+ return text.strip()
23
+
24
+ def ocr_image_pil(img):
25
+ """Applies light preprocessing to improve OCR accuracy."""
26
+ img = img.convert("RGB")
27
+ img = ImageOps.grayscale(img)
28
+ img = ImageEnhance.Contrast(img).enhance(1.6)
29
+ return img
30
+
31
+ def ocr_pdf_page(pdf_path: str, page_number_1based: int, dpi: int = OCR_DPI, lang: str = OCR_LANG) -> str:
32
+ """OCRs a single PDF page."""
33
+ images = convert_from_path(
34
+ str(pdf_path),
35
+ dpi=dpi,
36
+ first_page=page_number_1based,
37
+ last_page=page_number_1based,
38
+ fmt="png",
39
+ thread_count=2,
40
+ )
41
+ if not images:
42
+ return ""
43
+ img = images[0]
44
+ img = ocr_image_pil(img)
45
+ return pytesseract.image_to_string(img, lang=lang)
46
+
47
+ def pdf_to_text_smart(pdf_path: str, native_min_chars_per_page: int = NATIVE_MIN_CHARS_PER_PAGE) -> str:
48
+ """Extracts text from PDF, falling back to OCR for scanned pages."""
49
+ doc = fitz.open(str(pdf_path))
50
+ parts = []
51
+
52
+ for i in range(doc.page_count):
53
+ page = doc.load_page(i)
54
+ native = (page.get_text("text") or "").strip()
55
+ native_compact_len = len(re.sub(r"\s+", "", native))
56
+
57
+ if native_compact_len >= native_min_chars_per_page:
58
+ parts.append(native)
59
+ else:
60
+ ocr = ocr_pdf_page(pdf_path, page_number_1based=i+1)
61
+ parts.append(ocr)
62
+
63
+ doc.close()
64
+ return normalize_text("\n\n".join(parts))
65
+
66
+ def extract_text_from_file(file_path: str) -> str:
67
+ """Extracts text from a .txt or .pdf file."""
68
+ path = Path(file_path)
69
+ suf = path.suffix.lower()
70
+
71
+ if suf == ".txt":
72
+ raw = path.read_text(encoding="utf-8", errors="ignore")
73
+ return normalize_text(raw)
74
+
75
+ if suf == ".pdf":
76
+ return pdf_to_text_smart(str(path))
77
+
78
+ raise ValueError(f"Unsupported file type '{suf}'. Please upload .pdf or .txt only.")
79
+
80
+ def split_into_chapters(text: str) -> List[Tuple[str, str]]:
81
+ """
82
+ Best effort chapter split:
83
+ - Detect lines that look like: CHAPTER 1 / Chapter One / CHAPTER ONE etc.
84
+ - If not found, return one chapter = full text.
85
+ Returns: list of (title, body)
86
+ """
87
+ text = normalize_text(text)
88
+ lines = text.splitlines()
89
+
90
+ chapter_re = re.compile(r"^\s*(chapter|CHAPTER)\s+([0-9]+|[IVXLC]+|[A-Za-z]+)\b.*$", re.IGNORECASE)
91
+
92
+ idxs = []
93
+ titles = []
94
+ for i, ln in enumerate(lines):
95
+ if chapter_re.match(ln.strip()):
96
+ idxs.append(i)
97
+ titles.append(ln.strip())
98
+
99
+ if len(idxs) < 2:
100
+ return [("BOOK", text)]
101
+
102
+ chapters = []
103
+ for k in range(len(idxs)):
104
+ start = idxs[k]
105
+ end = idxs[k+1] if k+1 < len(idxs) else len(lines)
106
+ title = titles[k]
107
+ body = "\n".join(lines[start:end]).strip()
108
+ chapters.append((title, body))
109
+ return chapters
110
+
111
+ def split_sentences(paragraph: str) -> List[str]:
112
+ """Splits a paragraph into sentences."""
113
+ paragraph = paragraph.strip()
114
+ if not paragraph:
115
+ return []
116
+ if not any(ch in paragraph for ch in ".!?\u061F\u06D4\u061B…"):
117
+ ls = [ln.strip() for ln in paragraph.split("\n") if ln.strip()]
118
+ return ls if ls else [paragraph]
119
+ return [s.strip() for s in _SENT_BOUNDARY_RE.split(paragraph) if s.strip()]
120
+
121
+ def iter_paragraphs(text: str):
122
+ """Yields paragraphs from text."""
123
+ for p in re.split(r"\n\s*\n+", text):
124
+ p = p.strip()
125
+ if p:
126
+ yield p
notebook_code.py ADDED
@@ -0,0 +1,458 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =========================
2
+ # Cell 1 — Install deps (Colab)
3
+ # =========================
4
+ !apt-get -qq update
5
+ !apt-get -qq install -y poppler-utils tesseract-ocr tesseract-ocr-eng tesseract-ocr-ara
6
+ !pip -q install -U transformers accelerate sentencepiece pymupdf pdf2image pytesseract pillow tqdm
7
+ # =========================
8
+ # Cell 2 — Imports + Config
9
+ # =========================
10
+ import os, re, json
11
+ from pathlib import Path
12
+ from math import ceil
13
+
14
+ import torch
15
+ from tqdm.auto import tqdm
16
+
17
+ import fitz # pymupdf
18
+ from pdf2image import convert_from_path
19
+ import pytesseract
20
+ from PIL import ImageOps, ImageEnhance
21
+
22
+ from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
23
+
24
+ OUTPUT_DIR = Path("/content/output")
25
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
26
+
27
+ # Model (English-focused)
28
+ MODEL_NAME = "facebook/bart-large-cnn" # https://huggingface.co/facebook/bart-large-cnn
29
+
30
+ # OCR
31
+ OCR_LANG = "eng+ara"
32
+ OCR_DPI = 250
33
+ NATIVE_MIN_CHARS_PER_PAGE = 60 # if native extracted text < this => OCR that page
34
+
35
+ # Summarization quality/speed knobs
36
+ BATCH_SIZE = 4
37
+ NUM_BEAMS = 4
38
+ NO_REPEAT_NGRAM_SIZE = 3
39
+ EARLY_STOPPING = False
40
+
41
+ # Chunking
42
+ MAX_INPUT_TOKENS = 1024
43
+ HEADROOM_TOKENS = 16
44
+ EFFECTIVE_MAX_INPUT = MAX_INPUT_TOKENS - HEADROOM_TOKENS
45
+ OVERLAP_SENTENCES = 2
46
+
47
+ # Output size (big + محترم)
48
+ CHAPTER_MAX_NEW_TOKENS_CAP = 320 # max tokens generated per chapter summary
49
+ CHAPTER_MIN_NEW_TOKENS_FLOOR = 120
50
+ BOOK_PARTS = 8 # final organized "big" summary in N parts
51
+
52
+ device = "cuda" if torch.cuda.is_available() else "cpu"
53
+ print("Device:", device)
54
+ print("Output folder:", OUTPUT_DIR)
55
+ # =========================
56
+ # Cell 3 — Upload input (PDF or TXT)
57
+ # =========================
58
+ from google.colab import files
59
+
60
+ uploaded = files.upload()
61
+ INPUT_PATH = Path(next(iter(uploaded.keys()))).resolve()
62
+
63
+ print("Uploaded:", INPUT_PATH)
64
+ print("Suffix:", INPUT_PATH.suffix.lower())
65
+ # =========================
66
+ # Cell 4 — PDF/TXT -> Clean TXT (robust native + per-page OCR fallback)
67
+ # =========================
68
+ _SENT_BOUNDARY_RE = re.compile(r"(?<=[\.\!\?\u061F\u06D4\u061B…])\s+") # . ! ? ؟ ۔ ؛ …
69
+
70
+ def normalize_text(text: str) -> str:
71
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
72
+ text = re.sub(r"[ \t]+", " ", text)
73
+ text = re.sub(r"\n{3,}", "\n\n", text)
74
+ return text.strip()
75
+
76
+ def ocr_image_pil(img):
77
+ # Light preprocessing to improve OCR
78
+ img = img.convert("RGB")
79
+ img = ImageOps.grayscale(img)
80
+ img = ImageEnhance.Contrast(img).enhance(1.6)
81
+ return img
82
+
83
+ def ocr_pdf_page(pdf_path: Path, page_number_1based: int, dpi: int = OCR_DPI, lang: str = OCR_LANG) -> str:
84
+ images = convert_from_path(
85
+ str(pdf_path),
86
+ dpi=dpi,
87
+ first_page=page_number_1based,
88
+ last_page=page_number_1based,
89
+ fmt="png",
90
+ thread_count=2,
91
+ )
92
+ img = images[0]
93
+ img = ocr_image_pil(img)
94
+ return pytesseract.image_to_string(img, lang=lang)
95
+
96
+ def pdf_to_text_smart(pdf_path: Path,
97
+ native_min_chars_per_page: int = NATIVE_MIN_CHARS_PER_PAGE) -> str:
98
+ doc = fitz.open(str(pdf_path))
99
+ parts = []
100
+
101
+ for i in tqdm(range(doc.page_count), desc="Extracting pages"):
102
+ page = doc.load_page(i)
103
+ native = (page.get_text("text") or "").strip()
104
+ native_compact_len = len(re.sub(r"\s+", "", native))
105
+
106
+ if native_compact_len >= native_min_chars_per_page:
107
+ parts.append(native)
108
+ else:
109
+ ocr = ocr_pdf_page(pdf_path, page_number_1based=i+1)
110
+ parts.append(ocr)
111
+
112
+ doc.close()
113
+ return normalize_text("\n\n".join(parts))
114
+
115
+ def ensure_txt(input_path: Path) -> Path:
116
+ out_txt = OUTPUT_DIR / f"{input_path.stem}.txt"
117
+ suf = input_path.suffix.lower()
118
+
119
+ if suf == ".txt":
120
+ raw = input_path.read_text(encoding="utf-8", errors="ignore")
121
+ out_txt.write_text(normalize_text(raw), encoding="utf-8")
122
+ return out_txt
123
+
124
+ if suf == ".pdf":
125
+ text = pdf_to_text_smart(input_path)
126
+ out_txt.write_text(text, encoding="utf-8")
127
+ return out_txt
128
+
129
+ raise ValueError("Unsupported type. Upload .pdf or .txt only.")
130
+
131
+ BOOK_TXT_PATH = ensure_txt(INPUT_PATH)
132
+ BOOK_TEXT = BOOK_TXT_PATH.read_text(encoding="utf-8", errors="ignore")
133
+
134
+ print("Saved TXT:", BOOK_TXT_PATH)
135
+ print("Chars:", len(BOOK_TEXT))
136
+ print("Head preview:\n", BOOK_TEXT[:800])
137
+ # FIX Pillow broken install (PIL._typing/_Ink issue)
138
+ !pip -q uninstall -y Pillow pillow-simd
139
+ !pip -q install --no-cache-dir --force-reinstall "Pillow==10.4.0"
140
+
141
+ import PIL, sys
142
+ print("Pillow version:", PIL.__version__)
143
+ print("Python:", sys.version)
144
+ # =========================
145
+ # Cell 5 — Load tokenizer + model (from Hugging Face)
146
+ # =========================
147
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, use_fast=True)
148
+ model = AutoModelForSeq2SeqLM.from_pretrained(MODEL_NAME).to(device)
149
+
150
+ if device == "cuda":
151
+ try:
152
+ model.half()
153
+ except Exception:
154
+ pass
155
+
156
+ torch.set_grad_enabled(False)
157
+ print("Model loaded:", MODEL_NAME)
158
+ # =========================
159
+ # Cell 6 — Chapter splitting + token-aware chunking
160
+ # =========================
161
+ def split_into_chapters(text: str):
162
+ """
163
+ Best effort chapter split:
164
+ - Detect lines that look like: CHAPTER 1 / Chapter One / CHAPTER ONE etc.
165
+ - If not found, return one chapter = full text.
166
+ """
167
+ text = normalize_text(text)
168
+ lines = text.splitlines()
169
+
170
+ chapter_re = re.compile(r"^\s*(chapter|CHAPTER)\s+([0-9]+|[IVXLC]+|[A-Za-z]+)\b.*$", re.IGNORECASE)
171
+
172
+ idxs = []
173
+ titles = []
174
+ for i, ln in enumerate(lines):
175
+ if chapter_re.match(ln.strip()):
176
+ idxs.append(i)
177
+ titles.append(ln.strip())
178
+
179
+ if len(idxs) < 2:
180
+ return [("BOOK", text)]
181
+
182
+ chapters = []
183
+ for k in range(len(idxs)):
184
+ start = idxs[k]
185
+ end = idxs[k+1] if k+1 < len(idxs) else len(lines)
186
+ title = titles[k]
187
+ body = "\n".join(lines[start:end]).strip()
188
+ chapters.append((title, body))
189
+ return chapters
190
+
191
+ def split_sentences(paragraph: str):
192
+ paragraph = paragraph.strip()
193
+ if not paragraph:
194
+ return []
195
+ if not any(ch in paragraph for ch in ".!?\u061F\u06D4\u061B…"):
196
+ ls = [ln.strip() for ln in paragraph.split("\n") if ln.strip()]
197
+ return ls if ls else [paragraph]
198
+ return [s.strip() for s in _SENT_BOUNDARY_RE.split(paragraph) if s.strip()]
199
+
200
+ def iter_paragraphs(text: str):
201
+ for p in re.split(r"\n\s*\n+", text):
202
+ p = p.strip()
203
+ if p:
204
+ yield p
205
+
206
+ def tok_len(s: str) -> int:
207
+ return len(tokenizer.encode(s, add_special_tokens=False))
208
+
209
+ def split_by_tokens(s: str, max_len: int, overlap_tokens: int = 64):
210
+ ids = tokenizer.encode(s, add_special_tokens=False)
211
+ if len(ids) <= max_len:
212
+ return [s.strip()]
213
+ overlap_tokens = max(0, min(overlap_tokens, max_len // 3))
214
+ step = max(1, max_len - overlap_tokens)
215
+ parts = []
216
+ for i in range(0, len(ids), step):
217
+ chunk_ids = ids[i:i+max_len]
218
+ if not chunk_ids:
219
+ continue
220
+ t = tokenizer.decode(chunk_ids, skip_special_tokens=True, clean_up_tokenization_spaces=True).strip()
221
+ if t:
222
+ parts.append(t)
223
+ return parts
224
+
225
+ def chunk_text(text: str, max_input_tokens: int = EFFECTIVE_MAX_INPUT, overlap_sentences: int = OVERLAP_SENTENCES):
226
+ """
227
+ Professional chunking:
228
+ - pack sentences under token limit
229
+ - add sentence overlap between chunks for continuity
230
+ - if a single sentence is too long => token-split it
231
+ """
232
+ text = normalize_text(text)
233
+ if not text:
234
+ return []
235
+
236
+ chunks = []
237
+ cur_sents, cur_tok = [], 0
238
+
239
+ def flush():
240
+ nonlocal cur_sents, cur_tok
241
+ if cur_sents:
242
+ ch = " ".join(cur_sents).strip()
243
+ if ch:
244
+ chunks.append(ch)
245
+ cur_sents, cur_tok = [], 0
246
+
247
+ for para in iter_paragraphs(text):
248
+ for sent in split_sentences(para):
249
+ st = sent.strip()
250
+ if not st:
251
+ continue
252
+ st_tok = tok_len(st)
253
+
254
+ if st_tok > max_input_tokens:
255
+ flush()
256
+ chunks.extend(split_by_tokens(st, max_len=max_input_tokens, overlap_tokens=64))
257
+ continue
258
+
259
+ if cur_tok + st_tok <= max_input_tokens:
260
+ cur_sents.append(st)
261
+ cur_tok += st_tok
262
+ else:
263
+ prev = cur_sents[:]
264
+ flush()
265
+ overlap = prev[-overlap_sentences:] if overlap_sentences and prev else []
266
+ cur_sents = overlap + [st]
267
+ cur_tok = tok_len(" ".join(cur_sents))
268
+
269
+ flush()
270
+ return chunks
271
+ # =========================
272
+ # Cell 7 — Summarization helpers (map -> reduce) + "organized big summary"
273
+ # =========================
274
+ @torch.no_grad()
275
+ def generate_summaries(texts, min_new_tokens, max_new_tokens, batch_size=BATCH_SIZE):
276
+ outs = []
277
+ for i in range(0, len(texts), batch_size):
278
+ batch = texts[i:i+batch_size]
279
+ enc = tokenizer(
280
+ batch, return_tensors="pt",
281
+ truncation=True, padding=True,
282
+ max_length=EFFECTIVE_MAX_INPUT
283
+ ).to(device)
284
+
285
+ try:
286
+ gen = model.generate(
287
+ **enc,
288
+ num_beams=NUM_BEAMS,
289
+ no_repeat_ngram_size=NO_REPEAT_NGRAM_SIZE,
290
+ min_new_tokens=min_new_tokens,
291
+ max_new_tokens=max_new_tokens,
292
+ early_stopping=EARLY_STOPPING,
293
+ )
294
+ except TypeError:
295
+ # fallback for older transformers
296
+ gen = model.generate(
297
+ **enc,
298
+ num_beams=NUM_BEAMS,
299
+ no_repeat_ngram_size=NO_REPEAT_NGRAM_SIZE,
300
+ min_length=min_new_tokens,
301
+ max_length=max_new_tokens,
302
+ early_stopping=EARLY_STOPPING,
303
+ )
304
+
305
+ decoded = tokenizer.batch_decode(gen, skip_special_tokens=True, clean_up_tokenization_spaces=True)
306
+ outs.extend([d.strip() for d in decoded])
307
+ return outs
308
+
309
+ def summarize_long_text(text: str, min_new: int, max_new: int):
310
+ """
311
+ Summarize very long text reliably:
312
+ - chunk -> summarize each chunk
313
+ - if multiple chunk summaries, reduce them into one (still ordered)
314
+ """
315
+ chunks = chunk_text(text)
316
+ if not chunks:
317
+ return ""
318
+
319
+ # summarize chunks
320
+ chunk_summaries = []
321
+ for ch in chunks:
322
+ tlen = tok_len(ch)
323
+ # dynamic summary size per chunk (keeps it detailed)
324
+ dyn_max = int(min(max_new, max(min_new, round(tlen * 0.18))))
325
+ dyn_min = max(30, min(min_new, dyn_max - 10))
326
+ chunk_summaries.append(generate_summaries([ch], dyn_min, dyn_max, batch_size=1)[0])
327
+
328
+ if len(chunk_summaries) == 1:
329
+ return chunk_summaries[0]
330
+
331
+ # reduce in groups (keeps order)
332
+ current = chunk_summaries
333
+ for _ in range(6):
334
+ combined = "\n".join([f"Part {i+1}: {t}" for i, t in enumerate(current)])
335
+ if tok_len(combined) <= EFFECTIVE_MAX_INPUT:
336
+ return generate_summaries([combined], min_new, max_new, batch_size=1)[0]
337
+
338
+ # too long -> chunk combined summaries and summarize each chunk
339
+ sub_chunks = chunk_text(combined, overlap_sentences=1)
340
+ current = generate_summaries(
341
+ sub_chunks,
342
+ min_new_tokens=max(60, min_new // 2),
343
+ max_new_tokens=max(180, max_new // 2),
344
+ batch_size=BATCH_SIZE
345
+ )
346
+ return "\n".join(current).strip()
347
+
348
+ def make_big_book_summary(chapter_summaries, parts=BOOK_PARTS):
349
+ """
350
+ Organized "big" summary:
351
+ - group chapter summaries into N parts
352
+ - summarize each group into a longer part-summary
353
+ - output stays structured and chronological
354
+ """
355
+ chap_summaries = [s for s in chapter_summaries if s.strip()]
356
+ if not chap_summaries:
357
+ return []
358
+
359
+ n = len(chap_summaries)
360
+ group_size = max(1, ceil(n / parts))
361
+ groups = [chap_summaries[i:i+group_size] for i in range(0, n, group_size)]
362
+
363
+ part_summaries = []
364
+ for gi, g in enumerate(tqdm(groups, desc="Building big organized summary")):
365
+ combined = "\n".join([f"ChapterSummary {gi+1}.{i+1}: {t}" for i, t in enumerate(g)])
366
+ ps = summarize_long_text(combined, min_new=220, max_new=520)
367
+ part_summaries.append(ps.strip())
368
+ return part_summaries
369
+ # =========================
370
+ # Cell 8 — RUN: chapter summaries + big organized summary + save all outputs
371
+ # =========================
372
+ chapters = split_into_chapters(BOOK_TEXT)
373
+ print("Detected chapters:", len(chapters))
374
+ print("First chapter title:", chapters[0][0])
375
+
376
+ # Save chapters as separate txt files (for debugging)
377
+ chapters_dir = OUTPUT_DIR / f"{BOOK_TXT_PATH.stem}_chapters"
378
+ chapters_dir.mkdir(parents=True, exist_ok=True)
379
+
380
+ chapter_summaries = []
381
+ chapter_meta = []
382
+
383
+ for idx, (title, body) in enumerate(tqdm(chapters, desc="Summarizing chapters")):
384
+ safe_title = re.sub(r"[^A-Za-z0-9 _-]+", "", title)[:80].strip().replace(" ", "_")
385
+ ch_txt_path = chapters_dir / f"{idx+1:03d}_{safe_title or 'CHAPTER'}.txt"
386
+ ch_txt_path.write_text(body, encoding="utf-8")
387
+
388
+ # chapter summary (detailed)
389
+ # (إذا الفصل طويل جدًا summarize_long_text هيعمل chunking داخليًا)
390
+ summary = summarize_long_text(
391
+ body,
392
+ min_new=CHAPTER_MIN_NEW_TOKENS_FLOOR,
393
+ max_new=CHAPTER_MAX_NEW_TOKENS_CAP
394
+ )
395
+
396
+ chapter_summaries.append(summary)
397
+ chapter_meta.append({"index": idx+1, "title": title, "txt_path": str(ch_txt_path)})
398
+
399
+ # 1) Save per-chapter summaries (organized)
400
+ chapter_summaries_path = OUTPUT_DIR / f"{BOOK_TXT_PATH.stem}.chapter_summaries.txt"
401
+ with chapter_summaries_path.open("w", encoding="utf-8") as f:
402
+ for i, (meta, summ) in enumerate(zip(chapter_meta, chapter_summaries), start=1):
403
+ f.write(f"===== CHAPTER {i}: {meta['title']} =====\n")
404
+ f.write(summ.strip() + "\n\n")
405
+
406
+ # 2) Save "big organized book summary" (multi-part, محترم وكبير)
407
+ big_parts = make_big_book_summary(chapter_summaries, parts=BOOK_PARTS)
408
+ big_summary_path = OUTPUT_DIR / f"{BOOK_TXT_PATH.stem}.BIG_book_summary_parts.txt"
409
+ big_summary_path.write_text(
410
+ "\n\n".join([f"=== BOOK SUMMARY PART {i+1} ===\n{p}" for i, p in enumerate(big_parts)]),
411
+ encoding="utf-8"
412
+ )
413
+
414
+ # 3) Also save a single-file "full" summary by concatenating chapter summaries (very long, but super clear)
415
+ full_concat_path = OUTPUT_DIR / f"{BOOK_TXT_PATH.stem}.FULL_chapter_summaries_concat.txt"
416
+ full_concat_path.write_text("\n\n".join(chapter_summaries), encoding="utf-8")
417
+
418
+ # 4) Metadata
419
+ meta_path = OUTPUT_DIR / f"{BOOK_TXT_PATH.stem}.meta.json"
420
+ meta_path.write_text(json.dumps({
421
+ "input_file": str(INPUT_PATH),
422
+ "book_txt": str(BOOK_TXT_PATH),
423
+ "model": MODEL_NAME,
424
+ "device": device,
425
+ "chapters_detected": len(chapters),
426
+ "chapter_files_dir": str(chapters_dir),
427
+ "outputs": {
428
+ "chapter_summaries": str(chapter_summaries_path),
429
+ "big_book_summary_parts": str(big_summary_path),
430
+ "full_concat": str(full_concat_path),
431
+ }
432
+ }, ensure_ascii=False, indent=2), encoding="utf-8")
433
+
434
+ print("\nSaved outputs:")
435
+ print(" - Chapter summaries:", chapter_summaries_path)
436
+ print(" - BIG organized parts:", big_summary_path)
437
+ print(" - FULL concat:", full_concat_path)
438
+ print(" - Meta:", meta_path)
439
+
440
+ print("\nPreview BIG summary part 1:\n")
441
+ print(big_parts[0][:1500] if big_parts else "N/A")
442
+ # =========================
443
+ # Cell 9 — Save model + zip outputs + download
444
+ # =========================
445
+ saved_model_dir = OUTPUT_DIR / "saved_model_bart_large_cnn"
446
+ saved_model_dir.mkdir(parents=True, exist_ok=True)
447
+
448
+ model.save_pretrained(saved_model_dir)
449
+ tokenizer.save_pretrained(saved_model_dir)
450
+
451
+ print("Model saved to:", saved_model_dir)
452
+
453
+ zip_path = Path("/content/litvision_output.zip")
454
+ !zip -qr "{zip_path}" "{OUTPUT_DIR}"
455
+ print("Zipped to:", zip_path)
456
+
457
+ from google.colab import files
458
+ files.download(str(zip_path))