File size: 22,930 Bytes
95ff1e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621

import base64
import io
import json  # For parsing Vision API responses
from pathlib import Path
from typing import Dict, Any, List, Optional, Tuple
from dataclasses import dataclass
from loguru import logger
from PIL import Image
import fitz  # PyMuPDF - Pure Python, no poppler needed!
from openai import OpenAI


@dataclass
class VisionExtractionResult:
    """Result from vision-based extraction"""
    parameter_id: str
    parameter_name: str
    value: Any
    source: str  # Specific section/location
    page_number: int
    confidence: float
    context: str  # Surrounding text/context


class VisionDocumentParser:
    
    def __init__(self, openai_client: OpenAI, model: str = "gpt-4o"):
       
        self.client = openai_client
        self.model = model
        self._image_cache = {}  # Cache converted images by PDF path
        logger.info(f"VisionDocumentParser initialized with model: {model}")
    
    
    def pdf_to_images(self, pdf_path: str, dpi: int = 200) -> List[Image.Image]:
        
        try:
            # Check cache first - ONLY OPTIMIZATION!
            cache_key = f"{pdf_path}_{dpi}"
            if cache_key in self._image_cache:
                logger.info(f"✅ Using CACHED images for: {Path(pdf_path).name} (skipping conversion)")
                return self._image_cache[cache_key]
            
            logger.info(f"Converting PDF to images: {Path(pdf_path).name} (DPI: {dpi})")
            
            # Open PDF with PyMuPDF
            doc = fitz.open(pdf_path)
            images = []
            
            # Convert each page to image
            for page_num in range(len(doc)):
                page = doc[page_num]
                
                # Calculate zoom factor for DPI
                # 72 DPI is default, so zoom = target_dpi / 72
                zoom = dpi / 72
                mat = fitz.Matrix(zoom, zoom)
                
                # Render page to pixmap
                pix = page.get_pixmap(matrix=mat)
                
                # Convert pixmap to PIL Image
                img_data = pix.tobytes("png")
                img = Image.open(io.BytesIO(img_data))
                
                images.append(img)
            
            doc.close()
            
            # Cache for reuse - ONLY OPTIMIZATION!
            self._image_cache[cache_key] = images
            
            logger.success(f"Converted {len(images)} pages to images (PyMuPDF) - CACHED for reuse ✅")
            return images
            
        except Exception as e:
            logger.error(f"Error converting PDF to images: {str(e)}")
            return []
    
    
    def image_to_base64(self, image: Image.Image) -> str:
        
        try:
            buffered = io.BytesIO()
            image.save(buffered, format="PNG")
            img_str = base64.b64encode(buffered.getvalue()).decode()
            return img_str
            
        except Exception as e:
            logger.error(f"Error encoding image: {str(e)}")
            return ""
    
    
    def extract_all_parameters_from_page(

        self,

        image: Image.Image,

        page_num: int,

        parameters: List[Dict[str, str]]

    ) -> Dict[str, VisionExtractionResult]:
        
        try:
            # Build comprehensive prompt for ALL parameters
            param_descriptions = []
            for i, param in enumerate(parameters, 1):
                param_type = param.get('type', 'text')
                type_hint = {
                    'boolean': '(true/false)',
                    'number': '(numeric value)',
                    'date': '(date format)',
                    'text': '(text value)'
                }.get(param_type, '')
                
                param_descriptions.append(
                    f"{i}. **{param['name']}** {type_hint}: {param['description']}"
                )
            
            params_text = "\n".join(param_descriptions)
            
            prompt = f"""Analyze this document page and extract ALL of the following parameters that you can find:



{params_text}



IMPORTANT INSTRUCTIONS:

1. Return a JSON object with ONLY the parameters you found on this page

2. For each parameter found, provide:

   - "value": The actual value (use correct data type: number, boolean, string, or null)

   - "source": SPECIFIC location (e.g., "Account Summary Table - Settlement column, Row 2")

   - "confidence": Your confidence level (0.0 to 1.0)

   - "context": Brief surrounding text for verification



3. Skip parameters not visible on this page (don't include them in response)

4. Be precise with sources - include table names, section headers, row/column identifiers

5. For booleans, return true/false, NOT "yes"/"no" or 1/0



Return ONLY valid JSON, no markdown formatting:

{{

  "parameter_id_1": {{

    "found": true,

    "value": <actual_value>,

    "source": "Specific location with details",

    "confidence": 0.95,

    "context": "Surrounding text..."

  }},

  "parameter_id_2": {{

    "found": true,

    "value": <actual_value>,

    "source": "Another specific location",

    "confidence": 0.90,

    "context": "More context..."

  }}

}}



Parameter IDs to use: {', '.join([p['id'] for p in parameters])}"""

            # Convert image to base64
            buffered = io.BytesIO()
            image.save(buffered, format="PNG")
            img_base64 = base64.b64encode(buffered.getvalue()).decode()
            
            # Single API call for ALL parameters!
            response = self.client.chat.completions.create(
                model=self.model,
                messages=[
                    {
                        "role": "user",
                        "content": [
                            {
                                "type": "image_url",
                                "image_url": {
                                    "url": f"data:image/png;base64,{img_base64}"
                                }
                            },
                            {
                                "type": "text",
                                "text": prompt
                            }
                        ]
                    }
                ],
                max_tokens=2000,
                temperature=0.0
            )
            
            # Parse response
            content = response.choices[0].message.content.strip()
            
            # Remove markdown if present
            if content.startswith("```json"):
                content = content[7:]
            if content.startswith("```"):
                content = content[3:]
            if content.endswith("```"):
                content = content[:-3]
            content = content.strip()
            
            # Parse JSON
            results_dict = json.loads(content)
            
            # Create mapping of param_id to param_name for lookup
            param_name_map = {p['id']: p['name'] for p in parameters}
            
            # Convert to VisionExtractionResult objects
            extraction_results = {}
            for param_id, result_data in results_dict.items():
                if result_data.get('found', False):
                    extraction_results[param_id] = VisionExtractionResult(
                        parameter_id=param_id,
                        parameter_name=param_name_map.get(param_id, param_id),  # Get name from map
                        value=result_data.get('value'),
                        source=result_data.get('source', f'Page {page_num}'),
                        page_number=page_num,
                        confidence=result_data.get('confidence', 0.7),
                        context=result_data.get('context', '')
                    )
            
            logger.success(
                f"Page {page_num}: Found {len(extraction_results)}/{len(parameters)} parameters "
                f"in ONE call ⚡"
            )
            
            return extraction_results
            
        except json.JSONDecodeError as e:
            logger.error(f"Failed to parse JSON from page {page_num}: {str(e)}")
            return {}
        except Exception as e:
            logger.error(f"Error extracting from page {page_num}: {str(e)}")
            return {}
    
    def extract_all_parameters_batch(

        self,

        pdf_path: str,

        parameters: List[Dict[str, str]]

    ) -> Dict[str, VisionExtractionResult]:
        
        try:
            logger.info(
                f"⚡ BATCH EXTRACTION: Processing {len(parameters)} parameters "
                f"from {Path(pdf_path).name}"
            )
            
            # Convert PDF to images (uses cache!)
            images = self.pdf_to_images(pdf_path, dpi=200)
            if not images:
                logger.error("Failed to convert PDF to images")
                return {}
            
            # Store best result for each parameter
            best_results = {}
            
            # Process each page once, extracting ALL parameters
            for page_num, image in enumerate(images, start=1):
                logger.info(f"⚡ Page {page_num}/{len(images)}: Extracting ALL parameters...")
                
                # Extract all parameters from this page in ONE call!
                page_results = self.extract_all_parameters_from_page(
                    image=image,
                    page_num=page_num,
                    parameters=parameters
                )
                
                # Update best results (keep highest confidence for each parameter)
                for param_id, result in page_results.items():
                    if param_id not in best_results:
                        best_results[param_id] = result
                        logger.info(f"  ✓ {param_id}: {result.value} (conf: {result.confidence})")
                    elif result.confidence > best_results[param_id].confidence:
                        logger.info(
                            f"  ↑ {param_id}: {result.value} (conf: {result.confidence}) "
                            f"[better than {best_results[param_id].confidence}]"
                        )
                        best_results[param_id] = result
            
            found_count = len(best_results)
            logger.success(
                f"⚡ BATCH COMPLETE: Found {found_count}/{len(parameters)} parameters "
                f"in {len(images)} API calls (vs {len(parameters) * len(images)} with old method!)"
            )
            
            return best_results
            
        except Exception as e:
            logger.error(f"Error in batch extraction: {str(e)}")
            return {}
    
    def extract_parameter_from_page(

        self,

        image: Image.Image,

        page_num: int,

        parameter_name: str,

        parameter_description: str,

        parameter_type: str = "text"

    ) -> Optional[VisionExtractionResult]:
        
        try:
            # Convert image to base64
            img_base64 = self.image_to_base64(image)
            if not img_base64:
                return None
            
            # Build prompt based on parameter type
            prompt = self._build_extraction_prompt(
                parameter_name,
                parameter_description,
                parameter_type
            )
            
            # Call GPT-4 Vision
            response = self.client.chat.completions.create(
                model=self.model,
                messages=[
                    {
                        "role": "user",
                        "content": [
                            {
                                "type": "text",
                                "text": prompt
                            },
                            {
                                "type": "image_url",
                                "image_url": {
                                    "url": f"data:image/png;base64,{img_base64}",
                                    "detail": "high"
                                }
                            }
                        ]
                    }
                ],
                max_tokens=500,
                temperature=0.0  # Deterministic for data extraction
            )
            
            # Parse response
            result_text = response.choices[0].message.content
            
            # Parse structured response
            return self._parse_vision_response(
                result_text,
                parameter_name,
                page_num
            )
            
        except Exception as e:
            logger.error(f"Error extracting {parameter_name} from page {page_num}: {str(e)}")
            return None
    
    
    def _build_extraction_prompt(

        self,

        parameter_name: str,

        parameter_description: str,

        parameter_type: str

    ) -> str:
        """Build prompt for GPT-4 Vision extraction"""
        
        prompt = f"""You are analyzing a financial document (Bureau Credit Report or GST Return).



**TASK:** Extract the following parameter from this document page.



**Parameter Name:** {parameter_name}

**Description:** {parameter_description}

**Expected Type:** {parameter_type}



**INSTRUCTIONS:**

1. Look for this parameter in the document

2. If found, extract the exact value

3. Note the specific section/location where you found it (e.g., "Account Summary Table, Row 3" or "DPD History Section")

4. Provide surrounding context (nearby text)



**OUTPUT FORMAT (JSON):**

{{

    "found": true/false,

    "value": <extracted value or null>,

    "source": "<specific section/table/location>",

    "confidence": <0.0-1.0>,

    "context": "<surrounding text for verification>"

}}



**EXAMPLES:**



For "DPD 30 Days" in a credit report:

{{

    "found": true,

    "value": 2,

    "source": "Payment History Table - DPD 30 Days column",

    "confidence": 0.95,

    "context": "DPD History: 0-30 days: 2 occurrences"

}}



For "Settlement/Write-off" flag:

{{

    "found": true,

    "value": false,

    "source": "Account Status Summary - Settlement Status field",

    "confidence": 0.90,

    "context": "Settlement Status: Not Applicable, Write-off Status: No"

}}



If parameter not found on this page:

{{

    "found": false,

    "value": null,

    "source": "Not found on this page",

    "confidence": 0.0,

    "context": ""

}}



**CRITICAL RULES:**

- Be precise with locations (section names, table names, row/column)

- Extract EXACT values, don't interpret

- For boolean parameters, return true/false

- For numeric parameters, return numbers (not strings)

- If unsure, set confidence < 0.7

- Return ONLY valid JSON, no other text



Now analyze the document image and extract the parameter:"""
        
        return prompt
    
    
    def _parse_vision_response(

        self,

        response_text: str,

        parameter_id: str,

        page_num: int

    ) -> Optional[VisionExtractionResult]:
        """Parse GPT-4 Vision response into structured result"""
        try:
            import json
            
            # Extract JSON from response (handle markdown code blocks)
            json_text = response_text.strip()
            if "```json" in json_text:
                json_text = json_text.split("```json")[1].split("```")[0].strip()
            elif "```" in json_text:
                json_text = json_text.split("```")[1].split("```")[0].strip()
            
            # Parse JSON
            data = json.loads(json_text)
            
            # Check if found
            if not data.get("found", False):
                return None
            
            # Build result
            result = VisionExtractionResult(
                parameter_id=parameter_id,
                parameter_name=parameter_id.replace("_", " ").title(),
                value=data.get("value"),
                source=data.get("source", "Unknown location"),
                page_number=page_num,
                confidence=float(data.get("confidence", 0.5)),
                context=data.get("context", "")
            )
            
            return result
            
        except Exception as e:
            logger.error(f"Error parsing vision response: {str(e)}")
            logger.debug(f"Response text: {response_text}")
            return None
    
    
    def extract_parameter_from_pdf(

        self,

        pdf_path: str,

        parameter_name: str,

        parameter_description: str,

        parameter_type: str = "text",

        search_all_pages: bool = True  # Search all pages for best accuracy

    ) -> Optional[VisionExtractionResult]:
        
        try:
            logger.info(f"Extracting '{parameter_name}' from {Path(pdf_path).name}")
            
            # Convert PDF to images (uses cache if already converted! - ONLY OPTIMIZATION)
            images = self.pdf_to_images(pdf_path, dpi=200)
            if not images:
                logger.error("Failed to convert PDF to images")
                return None
            
            # Search pages
            results = []
            
            for page_num, image in enumerate(images, start=1):
                logger.info(f"Searching page {page_num}/{len(images)}...")
                
                result = self.extract_parameter_from_page(
                    image=image,
                    page_num=page_num,
                    parameter_name=parameter_name,
                    parameter_description=parameter_description,
                    parameter_type=parameter_type
                )
                
                if result and result.value is not None:
                    logger.success(f"Found on page {page_num}: {result.value} (confidence: {result.confidence})")
                    results.append(result)
                    
                    # Stop if we found a good match and not searching all pages
                    if not search_all_pages and result.confidence > 0.7:
                        break
            
            # Return best result
            if results:
                best_result = max(results, key=lambda r: r.confidence)
                logger.success(f"Best match: page {best_result.page_number}, confidence {best_result.confidence}")
                return best_result
            else:
                logger.warning(f"Parameter '{parameter_name}' not found in document")
                return None
                
        except Exception as e:
            logger.error(f"Error extracting parameter from PDF: {str(e)}")
            return None
    
    
    def extract_gst_sales_with_vision(

        self,

        pdf_path: str

    ) -> Optional[Dict[str, Any]]:
        
        try:
            logger.info(f"Extracting GST sales from {Path(pdf_path).name}")
            
            # Convert PDF to images
            images = self.pdf_to_images(pdf_path)
            if not images:
                return None
            
            # Prompt for GST sales
            prompt = """You are analyzing a GSTR-3B (GST Return) document.



**TASK:** Extract the total taxable sales value from Table 3.1(a).



**WHAT TO LOOK FOR:**

- Table 3.1(a): "Details of Outward Supplies and inward supplies liable to reverse charge"

- Look for "Taxable value" or "Total Taxable value"

- This is usually in the first row of Table 3.1



**OUTPUT FORMAT (JSON):**

{{

    "found": true/false,

    "month": "<month and year, e.g., January 2025>",

    "sales": <numeric value>,

    "source": "GSTR-3B Table 3.1(a)",

    "confidence": <0.0-1.0>

}}



**EXAMPLE:**

{{

    "found": true,

    "month": "January 2025",

    "sales": 951381,

    "source": "GSTR-3B Table 3.1(a) - Taxable outward supplies",

    "confidence": 0.95

}}



Return ONLY valid JSON, no other text."""
            
            # Try each page
            for page_num, image in enumerate(images, start=1):
                try:
                    img_base64 = self.image_to_base64(image)
                    
                    response = self.client.chat.completions.create(
                        model=self.model,
                        messages=[
                            {
                                "role": "user",
                                "content": [
                                    {"type": "text", "text": prompt},
                                    {
                                        "type": "image_url",
                                        "image_url": {
                                            "url": f"data:image/png;base64,{img_base64}",
                                            "detail": "high"
                                        }
                                    }
                                ]
                            }
                        ],
                        max_tokens=300,
                        temperature=0.0
                    )
                    
                    result_text = response.choices[0].message.content
                    
                    # Parse JSON
                    import json
                    json_text = result_text.strip()
                    if "```json" in json_text:
                        json_text = json_text.split("```json")[1].split("```")[0].strip()
                    elif "```" in json_text:
                        json_text = json_text.split("```")[1].split("```")[0].strip()
                    
                    data = json.loads(json_text)
                    
                    if data.get("found") and data.get("sales"):
                        logger.success(f"Found GST sales on page {page_num}: {data['sales']}")
                        return {
                            "month": data.get("month", "Unknown"),
                            "sales": data["sales"],
                            "source": data.get("source", "GSTR-3B Table 3.1(a)")
                        }
                        
                except Exception as e:
                    logger.debug(f"Page {page_num} - no sales data: {str(e)}")
                    continue
            
            logger.warning("GST sales not found in document")
            return None
            
        except Exception as e:
            logger.error(f"Error extracting GST sales: {str(e)}")
            return None