CoolFace
Apppublic

gauravmeena0708/epfo-circulars

sourceHugging Faceupdated 26d agoView on Hugging Face
0likes
document_assistant.py254 linesDownload Raw Back to root
1"""Document-ingestion and prompt helpers for the uploaded-file assistant."""2 3from __future__ import annotations4 5from dataclasses import dataclass6import hashlib7import re8from typing import Callable, Iterable, Mapping, Sequence9 10import numpy as np11 12try:13    import pymupdf as fitz14except ImportError:  # pragma: no cover - compatibility with older PyMuPDF releases15    import fitz16 17 18PAGE_MARKER_PATTERN = re.compile(r"(?=^--- \[Page \d+ / )", re.MULTILINE)19WORD_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{2,}")20QUERY_STOP_WORDS = {21    "about", "all", "and", "answer", "document", "file", "from", "have",22    "noting", "page", "provide", "sheet", "that", "the", "this", "what",23    "when", "where", "which", "with",24}25 26 27class DocumentExtractionError(ValueError):28    """A safe, user-facing PDF extraction failure."""29 30 31@dataclass(frozen=True)32class ExtractionResult:33    text: str34    page_count: int35    scanned_page_count: int36    warnings: tuple[str, ...]37 38 39def uploaded_file_signature(filename: str, data: bytes) -> str:40    """Return a stable signature that changes when either name or bytes change."""41    digest = hashlib.sha256()42    digest.update(filename.encode("utf-8", errors="replace"))43    digest.update(b"\0")44    digest.update(data)45    return digest.hexdigest()46 47 48def _normalise_ocr_results(results: Iterable[object]) -> str:49    lines = []50    for item in results:51        if isinstance(item, str):52            text = item53        elif isinstance(item, (list, tuple)) and len(item) >= 2:54            text = item[1]55        else:56            continue57        text = str(text).strip()58        if text:59            lines.append(text)60    return "\n".join(lines)61 62 63def _ocr_page(page, ocr_reader, dpi: int) -> str:64    """Render a PyMuPDF page and run EasyOCR without requiring Poppler."""65    scale = max(dpi, 72) / 7266    pixmap = page.get_pixmap(matrix=fitz.Matrix(scale, scale), alpha=False)67    channels = pixmap.n68    image = np.frombuffer(pixmap.samples, dtype=np.uint8).reshape(69        pixmap.height,70        pixmap.width,71        channels,72    )73    results = ocr_reader.readtext(image, detail=0, paragraph=True)74    return _normalise_ocr_results(results)75 76 77def extract_pdf_text(78    pdf_data: bytes,79    *,80    ocr_reader_factory: Callable[[], object] | None = None,81    native_text_min_words: int = 25,82    ocr_dpi: int = 200,83) -> ExtractionResult:84    """Extract page-labelled text with lazy OCR and guaranteed temp cleanup."""85    if not pdf_data:86        raise DocumentExtractionError("The uploaded PDF is empty.")87 88    try:89        document = fitz.open(stream=pdf_data, filetype="pdf")90    except Exception as exc:91        raise DocumentExtractionError(92            "The uploaded file is not a readable PDF or is damaged."93        ) from exc94 95    with document:96        if document.needs_pass:97            raise DocumentExtractionError(98                "This PDF is password-protected. Upload an unlocked copy."99            )100 101        page_count = len(document)102        if page_count == 0:103            raise DocumentExtractionError("The uploaded PDF has no pages.")104 105        extracted_pages: list[str | None] = [None] * page_count106        native_fallbacks: dict[int, str] = {}107        scanned_indices: list[int] = []108 109        for page_number in range(page_count):110            page = document[page_number]111            native_text = (page.get_text("text") or "").strip()112            marker = f"--- [Page {page_number + 1} / Note Page] ---"113            if len(native_text.split()) >= native_text_min_words:114                extracted_pages[page_number] = f"{marker}\n{native_text}"115            else:116                native_fallbacks[page_number] = native_text117                scanned_indices.append(page_number)118 119        warnings: list[str] = []120        ocr_reader = None121        if scanned_indices and ocr_reader_factory is not None:122            try:123                ocr_reader = ocr_reader_factory()124            except Exception:125                warnings.append(126                    "OCR could not be initialized. Short native text was preserved "127                    "where available, but some scanned pages may be unreadable."128                )129 130        unreadable_pages: list[int] = []131        for page_number in scanned_indices:132            ocr_text = ""133            if ocr_reader is not None:134                try:135                    ocr_text = _ocr_page(document[page_number], ocr_reader, ocr_dpi)136                except Exception:137                    unreadable_pages.append(page_number + 1)138 139            fallback_text = native_fallbacks.get(page_number, "")140            content = ocr_text or fallback_text141            page_type = "Scanned Page" if ocr_text else "Short/Scanned Page"142            if not content:143                if page_number + 1 not in unreadable_pages:144                    unreadable_pages.append(page_number + 1)145                content = "[No readable text could be extracted from this page.]"146            extracted_pages[page_number] = (147                f"--- [Page {page_number + 1} / {page_type}] ---\n{content}"148            )149 150        if unreadable_pages:151            page_list = ", ".join(str(number) for number in unreadable_pages)152            warnings.append(f"No readable text was found on page(s): {page_list}.")153 154        full_text = "\n\n".join(page for page in extracted_pages if page)155        return ExtractionResult(156            text=full_text,157            page_count=page_count,158            scanned_page_count=len(scanned_indices),159            warnings=tuple(warnings),160        )161 162 163def _truncate_middle(text: str, max_chars: int) -> str:164    if len(text) <= max_chars:165        return text166    separator = "\n\n[... content omitted to fit the model context ...]\n\n"167    available = max(0, max_chars - len(separator))168    head = available // 2169    tail = available - head170    return f"{text[:head]}{separator}{text[-tail:] if tail else ''}"171 172 173def select_document_context(174    full_text: str,175    query: str,176    max_chars: int,177) -> tuple[str, bool]:178    """Bound document input while retaining boundary and query-relevant pages."""179    max_chars = max(2_000, int(max_chars))180    if len(full_text) <= max_chars:181        return full_text, False182 183    page_blocks = [block.strip() for block in PAGE_MARKER_PATTERN.split(full_text) if block.strip()]184    if len(page_blocks) <= 1:185        return _truncate_middle(full_text, max_chars), True186 187    query_terms = {188        term.lower()189        for term in WORD_PATTERN.findall(query)190        if term.lower() not in QUERY_STOP_WORDS191    }192 193    scored_indices = []194    for index, block in enumerate(page_blocks):195        lowered = block.lower()196        relevance = sum(lowered.count(term) for term in query_terms)197        scored_indices.append((relevance, index))198 199    candidates = sorted(scored_indices, key=lambda item: (-item[0], item[1]))200    relevant_indices = [index for score, index in candidates if score > 0][:3]201    mandatory_indices = {0, len(page_blocks) - 1, *relevant_indices}202    # With broad prompts, evenly distributed candidates give summaries better coverage.203    evenly_spaced = sorted(204        {205            round(position * (len(page_blocks) - 1) / 7)206            for position in range(1, 7)207        }208    )209    candidate_indices = [index for _, index in candidates] + evenly_spaced210 211    notice = (212        "[Document context was limited for this request. The first, last, and "213        "most query-relevant pages are included below.]\n\n"214    )215    budget = max_chars - len(notice)216    separator_cost = 2 * max(0, len(mandatory_indices) - 1)217    per_mandatory_budget = max(218        200,219        (budget - separator_cost) // max(1, len(mandatory_indices)),220    )221    rendered_blocks = {222        index: _truncate_middle(page_blocks[index], per_mandatory_budget)223        for index in mandatory_indices224    }225    used_chars = sum(len(block) + 2 for block in rendered_blocks.values())226 227    for index in candidate_indices:228        if index in rendered_blocks:229            continue230        block_size = len(page_blocks[index]) + 2231        if used_chars + block_size <= budget:232            rendered_blocks[index] = page_blocks[index]233            used_chars += block_size234 235    ordered_blocks = [rendered_blocks[index] for index in sorted(rendered_blocks)]236    context = notice + "\n\n".join(ordered_blocks)237    return _truncate_middle(context, max_chars), True238 239 240def format_conversation_history(241    history: Sequence[Mapping[str, object]],242    *,243    max_chars: int,244    max_messages: int = 8,245) -> str:246    """Return the most recent bounded conversation turns for follow-up questions."""247    formatted: list[str] = []248    for message in history[-max_messages:]:249        role = "User" if message.get("role") == "user" else "Assistant"250        content = str(message.get("content", "")).strip()251        if content:252            formatted.append(f"{role}: {content}")253    return _truncate_middle("\n\n".join(formatted), max(500, max_chars))254