File size: 8,969 Bytes
bec23a3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65b1b02
 
 
 
 
 
 
 
bec23a3
65b1b02
 
bec23a3
65b1b02
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bec23a3
 
65b1b02
 
 
 
bec23a3
65b1b02
bec23a3
65b1b02
 
 
 
 
 
 
 
 
 
 
bec23a3
65b1b02
 
 
 
 
 
 
 
 
 
 
 
bec23a3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
"""Document-ingestion and prompt helpers for the uploaded-file assistant."""

from __future__ import annotations

from dataclasses import dataclass
import hashlib
import re
from typing import Callable, Iterable, Mapping, Sequence

import numpy as np

try:
    import pymupdf as fitz
except ImportError:  # pragma: no cover - compatibility with older PyMuPDF releases
    import fitz


PAGE_MARKER_PATTERN = re.compile(r"(?=^--- \[Page \d+ / )", re.MULTILINE)
WORD_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{2,}")
QUERY_STOP_WORDS = {
    "about", "all", "and", "answer", "document", "file", "from", "have",
    "noting", "page", "provide", "sheet", "that", "the", "this", "what",
    "when", "where", "which", "with",
}


class DocumentExtractionError(ValueError):
    """A safe, user-facing PDF extraction failure."""


@dataclass(frozen=True)
class ExtractionResult:
    text: str
    page_count: int
    scanned_page_count: int
    warnings: tuple[str, ...]


def uploaded_file_signature(filename: str, data: bytes) -> str:
    """Return a stable signature that changes when either name or bytes change."""
    digest = hashlib.sha256()
    digest.update(filename.encode("utf-8", errors="replace"))
    digest.update(b"\0")
    digest.update(data)
    return digest.hexdigest()


def _normalise_ocr_results(results: Iterable[object]) -> str:
    lines = []
    for item in results:
        if isinstance(item, str):
            text = item
        elif isinstance(item, (list, tuple)) and len(item) >= 2:
            text = item[1]
        else:
            continue
        text = str(text).strip()
        if text:
            lines.append(text)
    return "\n".join(lines)


def _ocr_page(page, ocr_reader, dpi: int) -> str:
    """Render a PyMuPDF page and run EasyOCR without requiring Poppler."""
    scale = max(dpi, 72) / 72
    pixmap = page.get_pixmap(matrix=fitz.Matrix(scale, scale), alpha=False)
    channels = pixmap.n
    image = np.frombuffer(pixmap.samples, dtype=np.uint8).reshape(
        pixmap.height,
        pixmap.width,
        channels,
    )
    results = ocr_reader.readtext(image, detail=0, paragraph=True)
    return _normalise_ocr_results(results)


def extract_pdf_text(
    pdf_data: bytes,
    *,
    ocr_reader_factory: Callable[[], object] | None = None,
    native_text_min_words: int = 25,
    ocr_dpi: int = 200,
) -> ExtractionResult:
    """Extract page-labelled text with lazy OCR and guaranteed temp cleanup."""
    if not pdf_data:
        raise DocumentExtractionError("The uploaded PDF is empty.")

    try:
        document = fitz.open(stream=pdf_data, filetype="pdf")
    except Exception as exc:
        raise DocumentExtractionError(
            "The uploaded file is not a readable PDF or is damaged."
        ) from exc

    with document:
        if document.needs_pass:
            raise DocumentExtractionError(
                "This PDF is password-protected. Upload an unlocked copy."
            )

        page_count = len(document)
        if page_count == 0:
            raise DocumentExtractionError("The uploaded PDF has no pages.")

        extracted_pages: list[str | None] = [None] * page_count
        native_fallbacks: dict[int, str] = {}
        scanned_indices: list[int] = []

        for page_number in range(page_count):
            page = document[page_number]
            native_text = (page.get_text("text") or "").strip()
            marker = f"--- [Page {page_number + 1} / Note Page] ---"
            if len(native_text.split()) >= native_text_min_words:
                extracted_pages[page_number] = f"{marker}\n{native_text}"
            else:
                native_fallbacks[page_number] = native_text
                scanned_indices.append(page_number)

        warnings: list[str] = []
        ocr_reader = None
        if scanned_indices and ocr_reader_factory is not None:
            try:
                ocr_reader = ocr_reader_factory()
            except Exception:
                warnings.append(
                    "OCR could not be initialized. Short native text was preserved "
                    "where available, but some scanned pages may be unreadable."
                )

        unreadable_pages: list[int] = []
        for page_number in scanned_indices:
            ocr_text = ""
            if ocr_reader is not None:
                try:
                    ocr_text = _ocr_page(document[page_number], ocr_reader, ocr_dpi)
                except Exception:
                    unreadable_pages.append(page_number + 1)

            fallback_text = native_fallbacks.get(page_number, "")
            content = ocr_text or fallback_text
            page_type = "Scanned Page" if ocr_text else "Short/Scanned Page"
            if not content:
                if page_number + 1 not in unreadable_pages:
                    unreadable_pages.append(page_number + 1)
                content = "[No readable text could be extracted from this page.]"
            extracted_pages[page_number] = (
                f"--- [Page {page_number + 1} / {page_type}] ---\n{content}"
            )

        if unreadable_pages:
            page_list = ", ".join(str(number) for number in unreadable_pages)
            warnings.append(f"No readable text was found on page(s): {page_list}.")

        full_text = "\n\n".join(page for page in extracted_pages if page)
        return ExtractionResult(
            text=full_text,
            page_count=page_count,
            scanned_page_count=len(scanned_indices),
            warnings=tuple(warnings),
        )


def _truncate_middle(text: str, max_chars: int) -> str:
    if len(text) <= max_chars:
        return text
    separator = "\n\n[... content omitted to fit the model context ...]\n\n"
    available = max(0, max_chars - len(separator))
    head = available // 2
    tail = available - head
    return f"{text[:head]}{separator}{text[-tail:] if tail else ''}"


def select_document_context(
    full_text: str,
    query: str,
    max_chars: int,
) -> tuple[str, bool]:
    """Bound document input while retaining boundary and query-relevant pages."""
    max_chars = max(2_000, int(max_chars))
    if len(full_text) <= max_chars:
        return full_text, False

    page_blocks = [block.strip() for block in PAGE_MARKER_PATTERN.split(full_text) if block.strip()]
    if len(page_blocks) <= 1:
        return _truncate_middle(full_text, max_chars), True

    query_terms = {
        term.lower()
        for term in WORD_PATTERN.findall(query)
        if term.lower() not in QUERY_STOP_WORDS
    }

    scored_indices = []
    for index, block in enumerate(page_blocks):
        lowered = block.lower()
        relevance = sum(lowered.count(term) for term in query_terms)
        scored_indices.append((relevance, index))

    candidates = sorted(scored_indices, key=lambda item: (-item[0], item[1]))
    relevant_indices = [index for score, index in candidates if score > 0][:3]
    mandatory_indices = {0, len(page_blocks) - 1, *relevant_indices}
    # With broad prompts, evenly distributed candidates give summaries better coverage.
    evenly_spaced = sorted(
        {
            round(position * (len(page_blocks) - 1) / 7)
            for position in range(1, 7)
        }
    )
    candidate_indices = [index for _, index in candidates] + evenly_spaced

    notice = (
        "[Document context was limited for this request. The first, last, and "
        "most query-relevant pages are included below.]\n\n"
    )
    budget = max_chars - len(notice)
    separator_cost = 2 * max(0, len(mandatory_indices) - 1)
    per_mandatory_budget = max(
        200,
        (budget - separator_cost) // max(1, len(mandatory_indices)),
    )
    rendered_blocks = {
        index: _truncate_middle(page_blocks[index], per_mandatory_budget)
        for index in mandatory_indices
    }
    used_chars = sum(len(block) + 2 for block in rendered_blocks.values())

    for index in candidate_indices:
        if index in rendered_blocks:
            continue
        block_size = len(page_blocks[index]) + 2
        if used_chars + block_size <= budget:
            rendered_blocks[index] = page_blocks[index]
            used_chars += block_size

    ordered_blocks = [rendered_blocks[index] for index in sorted(rendered_blocks)]
    context = notice + "\n\n".join(ordered_blocks)
    return _truncate_middle(context, max_chars), True


def format_conversation_history(
    history: Sequence[Mapping[str, object]],
    *,
    max_chars: int,
    max_messages: int = 8,
) -> str:
    """Return the most recent bounded conversation turns for follow-up questions."""
    formatted: list[str] = []
    for message in history[-max_messages:]:
        role = "User" if message.get("role") == "user" else "Assistant"
        content = str(message.get("content", "")).strip()
        if content:
            formatted.append(f"{role}: {content}")
    return _truncate_middle("\n\n".join(formatted), max(500, max_chars))