YLF-AI-backup / src /chatbot /document_hub.py
mohamedalaa-505
initilize deployment
6dbbc6f
Raw
History Blame Contribute Delete
5.56 kB
import os
import re
import hashlib
import threading
from pathlib import Path
from typing import Optional
# PDF Extraction
try:
from pypdf import PdfReader # preferred
except ImportError:
from PyPDF2 import PdfReader # fallback
# DOCUMENT STORAGE
# Stores extracted documents: { doc_id: { "filename", "chunks", "metadata" } }
documents_db = {}
doc_lock = threading.Lock()
# Chunking configuration
CHUNK_SIZE = 500 # characters per chunk
CHUNK_OVERLAP = 100 # overlap between consecutive chunks to preserve context
# Internal helpers
def _generate_doc_id(filename: str, content: str) -> str:
"""Create a stable, unique ID from filename + content hash."""
digest = hashlib.md5((filename + content[:200]).encode()).hexdigest()[:10]
return f"{Path(filename).stem}_{digest}"
def _chunk_text(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> list[dict]:
"""
Split *text* into overlapping fixed-size chunks, preserving the source
page number for each chunk.
Each chunk is a dict: { "text": str, "page": int }
Strategy
--------
1. Split the raw text on [Page N] markers produced by extract_text_from_pdf.
2. For each page block, slide a fixed-size window (with overlap) to
produce sub-chunks, all tagged with the page number they came from.
"""
chunks = []
# re.split with a capturing group keeps the delimiters in the result list:
# ['', '1', 'page1 text...', '2', 'page2 text...', ...]
page_blocks = re.split(r'\[Page (\d+)\]', text)
for i in range(1, len(page_blocks) - 1, 2):
page_num = int(page_blocks[i])
page_text = page_blocks[i + 1].strip()
start = 0
while start < len(page_text):
end = start + chunk_size
chunk_text = page_text[start:end].strip()
if chunk_text:
chunks.append({"text": chunk_text, "page": page_num})
start += chunk_size - overlap
return chunks
# Public API
def extract_text_from_pdf(file_path: str) -> str:
"""
Extract all text from a PDF file using pypdf (or PyPDF2 as fallback).
Each page is prefixed with a [Page N] marker so that downstream
chunking can recover the source page number.
Returns the concatenated text of all pages, or an error string if
extraction fails.
"""
path = Path(file_path)
if not path.exists():
return f"Error: File not found — {file_path}"
if path.suffix.lower() != ".pdf":
return f"Error: Expected a .pdf file, got '{path.suffix}'."
try:
reader = PdfReader(str(path))
pages_text = []
for i, page in enumerate(reader.pages):
page_text = page.extract_text() or ""
if page_text.strip():
# [Page N] marker is parsed by _chunk_text — keep format stable
pages_text.append(f"[Page {i + 1}]\n{page_text.strip()}")
if not pages_text:
return "Warning: No extractable text found. The PDF may be scanned/image-based."
return "\n\n".join(pages_text)
except Exception as e:
return f"Error extracting PDF: {str(e)}"
def process_document(file_path: str, filename: Optional[str] = None) -> dict:
"""
Full ingestion pipeline: extract → chunk (with page metadata) → store.
Parameters
----------
file_path : str
Absolute or relative path to the PDF file.
filename : str, optional
Display name for the document. Defaults to the file's basename.
Returns
-------
dict with keys: status, doc_id, filename, num_chunks
"""
filename = filename or Path(file_path).name
# 1. Extract raw text (with [Page N] markers)
raw_text = extract_text_from_pdf(file_path)
if raw_text.startswith("Error"):
return {"status": "error", "message": raw_text}
# 2. Chunk into page-aware dicts
chunks = _chunk_text(raw_text)
# 3. Stable document ID
doc_id = _generate_doc_id(filename, raw_text)
# 4. Thread-safe storage
with doc_lock:
documents_db[doc_id] = {
"filename": filename,
"raw_text": raw_text,
"chunks": chunks, # list[dict] {"text", "page"}
"metadata": {
"file_path": file_path,
"num_pages": raw_text.count("[Page "),
"num_chunks": len(chunks),
"chunk_size": CHUNK_SIZE,
"chunk_overlap": CHUNK_OVERLAP,
},
}
print(f"[DocumentHub] Processed '{filename}' → {len(chunks)} chunks (doc_id={doc_id})")
return {
"status": "success",
"doc_id": doc_id,
"filename": filename,
"num_chunks": len(chunks),
}
def get_document(doc_id: str) -> Optional[dict]:
"""Return the stored document record, or None if not found."""
with doc_lock:
return documents_db.get(doc_id)
def list_documents() -> list[dict]:
"""Return a summary list of all processed documents."""
with doc_lock:
return [
{
"doc_id": doc_id,
"filename": doc["filename"],
"num_chunks": doc["metadata"]["num_chunks"],
}
for doc_id, doc in documents_db.items()
]
def delete_document(doc_id: str) -> bool:
"""Remove a document from the store. Returns True if it existed."""
with doc_lock:
if doc_id in documents_db:
del documents_db[doc_id]
return True
return False