File size: 13,162 Bytes
3998131
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
"""
PDF Processing Module for Nepali Documents
Handles PDF extraction and sentence segmentation using LLM refinement
Uses PyMuPDF for extraction and Mistral LLM for sentence refinement
"""

import logging
import re
import json
from typing import List, Dict, Any, Optional
import fitz  # PyMuPDF

# Import Mistral client from module_a
from module_a.llm_client import MistralClient

logger = logging.getLogger(__name__)


class PDFProcessor:
    """
    Processes Nepali PDFs to extract and refine text into sentences.
    Uses PyMuPDF for PDF text extraction and Mistral LLM for sentence refinement.
    """

    def __init__(self, mistral_api_key: Optional[str] = None):
        """
        Initialize PDF Processor with Mistral client.

        Args:
            mistral_api_key: Optional Mistral API key (if not provided, uses env variable)
        """
        self.llm_client = MistralClient(api_key=mistral_api_key)
        logger.info("PDFProcessor initialized")

    def extract_text_from_pdf(self, pdf_path: str) -> str:
        """
        Extract raw text from PDF using PyMuPDF (fitz).

        Args:
            pdf_path: Path to the PDF file

        Returns:
            Extracted text from the PDF

        Raises:
            FileNotFoundError: If PDF file doesn't exist
            Exception: If PDF extraction fails
        """
        try:
            logger.info(f"Opening PDF: {pdf_path}")
            doc = fitz.open(pdf_path)
            
            full_text = ""
            for page_num, page in enumerate(doc):
                text = page.get_text("text")
                full_text += text + "\n"
                logger.debug(f"Extracted text from page {page_num + 1}")
            
            doc.close()
            
            if not full_text.strip():
                logger.warning("No text found in PDF. PDF might be image-based (requires OCR).")
                return ""
            
            logger.info(f"Successfully extracted {len(full_text)} characters from PDF")
            return full_text
            
        except FileNotFoundError:
            logger.error(f"PDF file not found: {pdf_path}")
            raise FileNotFoundError(f"PDF file not found: {pdf_path}")
        except Exception as e:
            logger.error(f"Error extracting text from PDF: {e}")
            raise

    def clean_text(self, text: str) -> str:
        """
        Clean extracted text by removing extra whitespace and fixing formatting.

        Args:
            text: Raw extracted text

        Returns:
            Cleaned text
        """
        # Replace multiple newlines with single space
        text = text.replace('\n', ' ')
        # Replace multiple spaces with single space
        text = re.sub(r'\s+', ' ', text)
        # Strip leading/trailing whitespace
        return text.strip()

    def split_into_sentences(self, text: str) -> List[str]:
        """
        Split Nepali text into sentences using regex pattern matching.
        Primary Nepali sentence boundary: । (danda/purna biram)
        Secondary boundaries: . ! ?

        Args:
            text: Cleaned text to split

        Returns:
            List of sentences
        """
        # Clean text first
        text = self.clean_text(text)
        
        if not text:
            logger.warning("Empty text provided for sentence splitting")
            return []

        # Improved Nepali sentence boundary pattern
        # Primary: Split on । (danda) - the main Nepali sentence terminator
        # This pattern splits on । even without space after it
        # Pattern explanation:
        # - (?<=।) : After a danda
        # - \s* : Optional whitespace (0 or more spaces)
        # - (?=[अ-हँ-ॿ]) : Followed by a Nepali character (lookahead)
        sentences = re.split(r'(?<=।)\s*(?=[अ-हँ-ॿ])', text)
        
        # If no danda found, try other punctuation
        if len(sentences) <= 1:
            # Split on other punctuation with or without space
            sentences = re.split(r'(?<=[।.!?])\s*(?=[अ-हँ-ॿ])', text)
        
        # Final fallback: split on any punctuation followed by space
        if len(sentences) <= 1:
            sentences = re.split(r'(?<=[।.!?])\s+', text)
        
        # Clean sentences: 
        # - Remove trailing punctuation marks
        # - Strip extra spaces
        # - Keep sentences with actual content (more than 3 characters after cleaning)
        cleaned_sentences = []
        for s in sentences:
            # Strip spaces and punctuation
            cleaned = s.strip(' ।.!?').strip()
            # Add back the danda for proper Nepali formatting
            if cleaned and len(cleaned) > 3:
                # If original sentence had danda, keep it
                if s.rstrip().endswith('।'):
                    cleaned_sentences.append(cleaned + '।')
                else:
                    cleaned_sentences.append(cleaned + '।')
        
        logger.info(f"Split text into {len(cleaned_sentences)} sentences")
        return cleaned_sentences

    def refine_sentences_with_llm(self, sentences: List[str]) -> List[str]:
        """
        Use Mistral LLM to refine and validate sentence segmentation.
        Helps correct any mis-segmented sentences, especially for Nepali text.

        Args:
            sentences: List of sentences to refine

        Returns:
            Refined list of sentences
        """
        if not sentences:
            logger.warning("No sentences provided for LLM refinement")
            return []

        # Combine sentences for batch processing
        combined_text = " ".join(sentences)
        
        system_prompt = """You are a Nepali text processing expert specialized in sentence segmentation. 
Your task is to:
1. Analyze the provided Nepali text carefully
2. Split text into complete, meaningful sentences
3. In Nepali, sentences end with "।" (danda/purna biram), not "."
4. Ensure each sentence is grammatically complete
5. Fix any incorrectly merged or split sentences
6. Remove duplicate sentences
7. Return ONLY a JSON array of properly segmented sentences

Important: Each sentence should end with "।" (danda). If a sentence is missing the danda, add it.

Return ONLY a valid JSON array of strings, nothing else. No explanations."""

        user_prompt = f"""Process this Nepali text and return properly segmented sentences as a JSON array.
Each sentence should be complete and end with "।" (danda).

Text: {combined_text}

Return format: ["sentence1।", "sentence2।", "sentence3।", ...]

Remember: 
- Split on "।" (danda) as the primary sentence boundary
- Each sentence should be complete and meaningful
- Return ONLY the JSON array"""

        try:
            logger.info("Sending sentences to Mistral for refinement")
            response = self.llm_client.generate_response(
                prompt=user_prompt,
                system_prompt=system_prompt,
                temperature=0.2  # Very low temperature for consistent sentence splitting
            )
            
            # Try to extract JSON array from response
            json_match = re.search(r'\[.*\]', response, re.DOTALL)
            if json_match:
                try:
                    refined_sentences = json.loads(json_match.group())
                    if isinstance(refined_sentences, list):
                        # Ensure all sentences end with danda
                        refined_sentences = [
                            s if s.endswith('।') else s + '।' 
                            for s in refined_sentences 
                            if str(s).strip()
                        ]
                        logger.info(f"LLM refined {len(sentences)} sentences to {len(refined_sentences)} sentences")
                        return refined_sentences
                except json.JSONDecodeError:
                    logger.warning("Could not parse JSON from LLM response, using original sentences")
                    return sentences
            else:
                logger.warning("Could not extract JSON from LLM response, using original sentences")
                return sentences
                
        except Exception as e:
            logger.warning(f"LLM refinement failed, using original sentences: {e}")
            return sentences

    def process_pdf(
        self, 
        pdf_path: str, 
        refine_with_llm: bool = True
    ) -> Dict[str, Any]:
        """
        Complete PDF processing pipeline: extract, clean, segment, and optionally refine.

        Args:
            pdf_path: Path to the PDF file
            refine_with_llm: Whether to use LLM for refinement (default: True)

        Returns:
            Dictionary with extraction results:
            {
                "success": bool,
                "sentences": List[str],
                "total_sentences": int,
                "raw_text": str,
                "error": Optional[str]
            }
        """
        try:
            # Step 1: Extract text from PDF
            raw_text = self.extract_text_from_pdf(pdf_path)
            
            if not raw_text:
                return {
                    "success": False,
                    "sentences": [],
                    "total_sentences": 0,
                    "raw_text": "",
                    "error": "No text could be extracted from the PDF"
                }
            
            # Step 2: Split into sentences
            sentences = self.split_into_sentences(raw_text)
            
            if not sentences:
                return {
                    "success": False,
                    "sentences": [],
                    "total_sentences": 0,
                    "raw_text": raw_text,
                    "error": "Could not segment sentences from extracted text"
                }
            
            # Step 3: Optionally refine with LLM
            if refine_with_llm:
                sentences = self.refine_sentences_with_llm(sentences)
            
            logger.info(f"Successfully processed PDF: {len(sentences)} sentences")
            
            return {
                "success": True,
                "sentences": sentences,
                "total_sentences": len(sentences),
                "raw_text": raw_text,
                "error": None
            }
            
        except Exception as e:
            logger.error(f"PDF processing failed: {e}")
            return {
                "success": False,
                "sentences": [],
                "total_sentences": 0,
                "raw_text": "",
                "error": str(e)
            }

    def process_pdf_from_bytes(
        self,
        pdf_bytes: bytes,
        refine_with_llm: bool = True
    ) -> Dict[str, Any]:
        """
        Process PDF from bytes (for file uploads via API).

        Args:
            pdf_bytes: PDF file contents as bytes
            refine_with_llm: Whether to use LLM for refinement (default: True)

        Returns:
            Dictionary with extraction results
        """
        try:
            logger.info("Processing PDF from bytes")
            
            # Open PDF from bytes
            doc = fitz.open(stream=pdf_bytes, filetype="pdf")
            
            full_text = ""
            for page_num, page in enumerate(doc):
                text = page.get_text("text")
                full_text += text + "\n"
                logger.debug(f"Extracted text from page {page_num + 1}")
            
            doc.close()
            
            if not full_text.strip():
                return {
                    "success": False,
                    "sentences": [],
                    "total_sentences": 0,
                    "raw_text": "",
                    "error": "No text found in PDF. PDF might be image-based (requires OCR)."
                }
            
            # Split into sentences
            sentences = self.split_into_sentences(full_text)
            
            if not sentences:
                return {
                    "success": False,
                    "sentences": [],
                    "total_sentences": 0,
                    "raw_text": full_text,
                    "error": "Could not segment sentences from extracted text"
                }
            
            # Optionally refine with LLM
            if refine_with_llm:
                sentences = self.refine_sentences_with_llm(sentences)
            
            logger.info(f"Successfully processed PDF from bytes: {len(sentences)} sentences")
            
            return {
                "success": True,
                "sentences": sentences,
                "total_sentences": len(sentences),
                "raw_text": full_text,
                "error": None
            }
            
        except Exception as e:
            logger.error(f"PDF processing from bytes failed: {e}")
            return {
                "success": False,
                "sentences": [],
                "total_sentences": 0,
                "raw_text": "",
                "error": str(e)
            }