Spaces:
Sleeping
Sleeping
Commit ·
d473af5
0
Parent(s):
Initial deployment setup for Summarization_Deploy
Browse files- Summarization_Feature.ipynb +0 -0
- hf_space_deployment/.dockerignore +10 -0
- hf_space_deployment/.gitattributes +1 -0
- hf_space_deployment/.gitignore +7 -0
- hf_space_deployment/Dockerfile +35 -0
- hf_space_deployment/README.md +114 -0
- hf_space_deployment/app.py +133 -0
- hf_space_deployment/requirements.txt +12 -0
- hf_space_deployment/summarizer.py +194 -0
- hf_space_deployment/utils.py +126 -0
- notebook_code.py +458 -0
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))
|