File size: 7,516 Bytes
30056dc
 
2fe3a9e
 
30056dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2fe3a9e
 
30056dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2fe3a9e
 
30056dc
 
 
 
 
 
 
2fe3a9e
30056dc
2fe3a9e
30056dc
 
 
 
2fe3a9e
30056dc
 
2fe3a9e
30056dc
2fe3a9e
30056dc
 
 
 
 
 
 
2fe3a9e
30056dc
2fe3a9e
30056dc
2fe3a9e
30056dc
 
 
 
 
 
 
 
2fe3a9e
 
30056dc
2fe3a9e
30056dc
2fe3a9e
30056dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2fe3a9e
30056dc
2fe3a9e
30056dc
 
 
 
 
 
 
 
 
2fe3a9e
30056dc
 
2fe3a9e
30056dc
 
 
 
2fe3a9e
30056dc
 
 
 
 
 
2fe3a9e
 
30056dc
 
 
 
2fe3a9e
30056dc
 
 
 
 
 
2fe3a9e
30056dc
2fe3a9e
30056dc
2fe3a9e
30056dc
2fe3a9e
30056dc
 
2fe3a9e
 
 
30056dc
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
"""utils/pdf_processor.py — PDF ingestion pipeline for the HF Space.

Standalone module mirroring src/rag_system/components/layout_parser.py logic:
PDF -> page images + raw text -> semantic chunk -> ready for embedding.
"""
from __future__ import annotations

import hashlib
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional, Tuple


@dataclass
class DocumentChunk:
    text: str
    page_number: int
    chunk_index: int
    source_filename: str
    chunk_type: str = "text"
    table_data: Optional[str] = None
    metadata: dict = field(default_factory=dict)

    @property
    def chunk_id(self) -> str:
        content = f"{self.source_filename}:{self.page_number}:{self.chunk_index}"
        return hashlib.sha256(content.encode()).hexdigest()[:12]


@dataclass
class IngestResult:
    filename: str
    num_pages: int
    num_chunks: int
    num_tables: int
    num_charts: int
    chunks: List[DocumentChunk]
    page_images: List[object]
    processing_steps: List[str]


def extract_text_and_tables(pdf_path: str) -> Tuple[List[dict], List[str]]:
    steps = []
    pages = []
    try:
        import pdfplumber
        steps.append("Opened PDF with pdfplumber")
        with pdfplumber.open(pdf_path) as pdf:
            steps.append(f"Found {len(pdf.pages)} pages")
            for i, page in enumerate(pdf.pages, 1):
                text = page.extract_text() or ""
                tables = page.extract_tables() or []
                pages.append({"page_num": i, "text": text.strip(), "tables": tables})
            steps.append(f"Extracted text from {len(pages)} pages")
            total_tables = sum(len(p["tables"]) for p in pages)
            if total_tables:
                steps.append(f"Found {total_tables} tables across all pages")
    except ImportError:
        steps.append("pdfplumber not available - trying PyPDF2 fallback")
        try:
            import PyPDF2
            with open(pdf_path, "rb") as f:
                reader = PyPDF2.PdfReader(f)
                for i, page in enumerate(reader.pages, 1):
                    text = page.extract_text() or ""
                    pages.append({"page_num": i, "text": text.strip(), "tables": []})
            steps.append(f"Extracted text (no table detection) from {len(pages)} pages")
        except Exception as e:
            steps.append(f"PDF extraction failed: {str(e)[:80]}")
    except Exception as e:
        steps.append(f"Extraction error: {str(e)[:80]}")
    return pages, steps


def render_page_images(pdf_path: str, max_pages: int = 8) -> Tuple[List[object], List[str]]:
    steps = []
    images = []
    try:
        from pdf2image import convert_from_path
        images = convert_from_path(pdf_path, dpi=150, first_page=1, last_page=max_pages)
        steps.append(f"Rendered {len(images)} page images at 150 DPI for vision analysis")
    except ImportError:
        steps.append("pdf2image not available - skipping page rendering")
    except Exception as e:
        steps.append(f"Page rendering failed: {str(e)[:80]}")
    return images, steps


def semantic_chunk_text(
    text: str,
    page_num: int,
    source: str,
    chunk_start_index: int = 0,
    max_chars: int = 800,
    overlap_chars: int = 100,
) -> List[DocumentChunk]:
    if not text.strip():
        return []
    paragraphs = [p.strip() for p in re.split(r"\n{2,}", text) if p.strip()]
    chunks: List[DocumentChunk] = []
    buffer = ""
    chunk_idx = chunk_start_index
    for para in paragraphs:
        if len(buffer) + len(para) < max_chars:
            buffer = (buffer + "\n\n" + para).strip() if buffer else para
        else:
            if buffer:
                chunks.append(DocumentChunk(
                    text=buffer, page_number=page_num, chunk_index=chunk_idx,
                    source_filename=source, chunk_type="text",
                ))
                chunk_idx += 1
                buffer = buffer[-overlap_chars:] + "\n\n" + para if len(buffer) > overlap_chars else para
            else:
                buffer = para
    if buffer.strip():
        chunks.append(DocumentChunk(
            text=buffer.strip(), page_number=page_num, chunk_index=chunk_idx,
            source_filename=source, chunk_type="text",
        ))
    return chunks


def format_table_as_text(table: List[List[str]]) -> str:
    if not table:
        return ""
    rows = []
    for row in table:
        cells = [str(c or "").strip() for c in row]
        rows.append(" | ".join(cells))
    return "\n".join(rows)


def chunk_tables(
    tables: List[List[List[str]]],
    page_num: int,
    source: str,
    chunk_start_index: int = 0,
) -> List[DocumentChunk]:
    chunks = []
    for t_idx, table in enumerate(tables):
        table_text = format_table_as_text(table)
        if table_text.strip():
            chunks.append(DocumentChunk(
                text=f"[TABLE on page {page_num}]\n{table_text}",
                page_number=page_num, chunk_index=chunk_start_index + t_idx,
                source_filename=source, chunk_type="table",
                table_data=table_text,
            ))
    return chunks


def ingest_pdf(pdf_path: str, process_vision: bool = True, vision_fn=None) -> IngestResult:
    filename = Path(pdf_path).name
    steps: List[str] = [f"Processing: {filename}"]
    all_chunks: List[DocumentChunk] = []
    page_images = []

    pages, extract_steps = extract_text_and_tables(pdf_path)
    steps.extend(extract_steps)

    chunk_counter = 0
    num_tables = 0
    for page in pages:
        text_chunks = semantic_chunk_text(page["text"], page["page_num"], filename, chunk_counter)
        all_chunks.extend(text_chunks)
        chunk_counter += len(text_chunks)
        table_chunks = chunk_tables(page["tables"], page["page_num"], filename, chunk_counter)
        all_chunks.extend(table_chunks)
        num_tables += len(table_chunks)
        chunk_counter += len(table_chunks)

    steps.append(f"Chunked into {len(all_chunks)} semantic chunks ({num_tables} tables)")

    num_charts = 0
    if process_vision and vision_fn is not None:
        imgs, img_steps = render_page_images(pdf_path)
        steps.extend(img_steps)
        page_images = imgs
        steps.append("Running vision LLM on page images...")
        for img_idx, img in enumerate(imgs[:6]):
            try:
                description = vision_fn(img)
                if description and len(description) > 30:
                    all_chunks.append(DocumentChunk(
                        text=f"[VISUAL CONTENT - page {img_idx + 1}]\n{description}",
                        page_number=img_idx + 1, chunk_index=chunk_counter,
                        source_filename=filename, chunk_type="chart_description",
                    ))
                    chunk_counter += 1
                    num_charts += 1
            except Exception as e:
                steps.append(f"Vision failed for page {img_idx + 1}: {str(e)[:60]}")
        if num_charts:
            steps.append(f"Generated {num_charts} visual descriptions from page images")
    elif process_vision:
        steps.append("Vision skipped - no vision model provided")

    steps.append(f"Ingestion complete: {len(all_chunks)} total chunks ready for retrieval")

    return IngestResult(
        filename=filename, num_pages=len(pages), num_chunks=len(all_chunks),
        num_tables=num_tables, num_charts=num_charts, chunks=all_chunks,
        page_images=page_images, processing_steps=steps,
    )