Add src directory
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- src/__init__.py +1 -0
- src/__pycache__/__init__.cpython-314.pyc +0 -0
- src/agents/__init__.py +1 -0
- src/agents/__pycache__/__init__.cpython-314.pyc +0 -0
- src/agents/__pycache__/annotation_agent.cpython-314.pyc +0 -0
- src/agents/__pycache__/base_agent.cpython-314.pyc +0 -0
- src/agents/__pycache__/content_validation_agent.cpython-314.pyc +0 -0
- src/agents/__pycache__/editing_agent.cpython-314.pyc +0 -0
- src/agents/__pycache__/export_agent.cpython-314.pyc +0 -0
- src/agents/__pycache__/export_rendering_agent.cpython-314.pyc +0 -0
- src/agents/__pycache__/format_correction_agent.cpython-314.pyc +0 -0
- src/agents/__pycache__/image_processing_agent.cpython-314.pyc +0 -0
- src/agents/__pycache__/language_detection_agent.cpython-314.pyc +0 -0
- src/agents/__pycache__/layout_analysis_agent.cpython-314.pyc +0 -0
- src/agents/__pycache__/ocr_agent.cpython-314.pyc +0 -0
- src/agents/__pycache__/structure_inspector.cpython-314.pyc +0 -0
- src/agents/__pycache__/table_detection_agent.cpython-314.pyc +0 -0
- src/agents/__pycache__/text_extraction_agent.cpython-314.pyc +0 -0
- src/agents/__pycache__/validation_agent.cpython-314.pyc +0 -0
- src/agents/annotation_agent.py +37 -0
- src/agents/base_agent.py +79 -0
- src/agents/content_validation/__init__.py +3 -0
- src/agents/content_validation/agent.py +43 -0
- src/agents/content_validation_agent.py +40 -0
- src/agents/editing_agent.py +48 -0
- src/agents/export_agent.py +24 -0
- src/agents/export_rendering/__init__.py +3 -0
- src/agents/export_rendering/agent.py +25 -0
- src/agents/export_rendering_agent.py +39 -0
- src/agents/format_correction/__init__.py +3 -0
- src/agents/format_correction/agent.py +32 -0
- src/agents/format_correction_agent.py +37 -0
- src/agents/image_processing/__init__.py +3 -0
- src/agents/image_processing/agent.py +33 -0
- src/agents/image_processing_agent.py +40 -0
- src/agents/language_detection/__init__.py +3 -0
- src/agents/language_detection/agent.py +39 -0
- src/agents/language_detection_agent.py +56 -0
- src/agents/layout_analysis/__init__.py +3 -0
- src/agents/layout_analysis/agent.py +37 -0
- src/agents/layout_analysis_agent.py +45 -0
- src/agents/ocr_agent.py +27 -0
- src/agents/structure_inspector.py +37 -0
- src/agents/table_detection/__init__.py +3 -0
- src/agents/table_detection/agent.py +41 -0
- src/agents/table_detection_agent.py +35 -0
- src/agents/text_extraction/__init__.py +3 -0
- src/agents/text_extraction/agent.py +36 -0
- src/agents/text_extraction_agent.py +45 -0
- src/agents/validation_agent.py +22 -0
src/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
src/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (158 Bytes). View file
|
|
|
src/agents/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
src/agents/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (165 Bytes). View file
|
|
|
src/agents/__pycache__/annotation_agent.cpython-314.pyc
ADDED
|
Binary file (3.38 kB). View file
|
|
|
src/agents/__pycache__/base_agent.cpython-314.pyc
ADDED
|
Binary file (5.78 kB). View file
|
|
|
src/agents/__pycache__/content_validation_agent.cpython-314.pyc
ADDED
|
Binary file (3.03 kB). View file
|
|
|
src/agents/__pycache__/editing_agent.cpython-314.pyc
ADDED
|
Binary file (4.7 kB). View file
|
|
|
src/agents/__pycache__/export_agent.cpython-314.pyc
ADDED
|
Binary file (2.01 kB). View file
|
|
|
src/agents/__pycache__/export_rendering_agent.cpython-314.pyc
ADDED
|
Binary file (2.99 kB). View file
|
|
|
src/agents/__pycache__/format_correction_agent.cpython-314.pyc
ADDED
|
Binary file (3.06 kB). View file
|
|
|
src/agents/__pycache__/image_processing_agent.cpython-314.pyc
ADDED
|
Binary file (2.88 kB). View file
|
|
|
src/agents/__pycache__/language_detection_agent.cpython-314.pyc
ADDED
|
Binary file (3.34 kB). View file
|
|
|
src/agents/__pycache__/layout_analysis_agent.cpython-314.pyc
ADDED
|
Binary file (3.57 kB). View file
|
|
|
src/agents/__pycache__/ocr_agent.cpython-314.pyc
ADDED
|
Binary file (2.28 kB). View file
|
|
|
src/agents/__pycache__/structure_inspector.cpython-314.pyc
ADDED
|
Binary file (2.34 kB). View file
|
|
|
src/agents/__pycache__/table_detection_agent.cpython-314.pyc
ADDED
|
Binary file (2.86 kB). View file
|
|
|
src/agents/__pycache__/text_extraction_agent.cpython-314.pyc
ADDED
|
Binary file (2.93 kB). View file
|
|
|
src/agents/__pycache__/validation_agent.cpython-314.pyc
ADDED
|
Binary file (1.75 kB). View file
|
|
|
src/agents/annotation_agent.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import fitz
|
| 4 |
+
from src.core.models import PDFOperation
|
| 5 |
+
|
| 6 |
+
class OfflineAnnotationAgent:
|
| 7 |
+
name = "annotation_agent"
|
| 8 |
+
|
| 9 |
+
def run(self, input_pdf: Path, output_pdf: Path, operations: list[PDFOperation]) -> Path:
|
| 10 |
+
doc = fitz.open(input_pdf)
|
| 11 |
+
for op in operations:
|
| 12 |
+
if op.page is None or op.page >= len(doc): continue
|
| 13 |
+
page = doc[op.page]
|
| 14 |
+
rect = fitz.Rect(op.rect) if op.rect else None
|
| 15 |
+
color = tuple(op.color) if op.color else (1, 1, 0)
|
| 16 |
+
if op.type == "highlight" and rect:
|
| 17 |
+
annot = page.add_highlight_annot(rect)
|
| 18 |
+
annot.set_colors(stroke=color)
|
| 19 |
+
annot.update()
|
| 20 |
+
elif op.type == "comment" and op.position and op.text:
|
| 21 |
+
annot = page.add_text_annot(fitz.Point(op.position), op.text)
|
| 22 |
+
annot.set_info(title=op.author, content=op.text)
|
| 23 |
+
annot.update()
|
| 24 |
+
elif op.type == "rectangle" and rect:
|
| 25 |
+
annot = page.add_rect_annot(rect)
|
| 26 |
+
annot.set_colors(stroke=color)
|
| 27 |
+
annot.update()
|
| 28 |
+
elif op.type == "redact" and rect:
|
| 29 |
+
page.add_redact_annot(rect, text=op.text or "", fill=(0,0,0))
|
| 30 |
+
for page in doc:
|
| 31 |
+
try:
|
| 32 |
+
page.apply_redactions(images=fitz.PDF_REDACT_IMAGE_REMOVE)
|
| 33 |
+
except Exception:
|
| 34 |
+
pass
|
| 35 |
+
output_pdf.parent.mkdir(parents=True, exist_ok=True)
|
| 36 |
+
doc.save(output_pdf, garbage=4, deflate=True, clean=True)
|
| 37 |
+
return output_pdf
|
src/agents/base_agent.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from abc import ABC, abstractmethod
|
| 3 |
+
from typing import Dict, Any, Optional, List
|
| 4 |
+
|
| 5 |
+
try:
|
| 6 |
+
from src.services.llm_client import LLMClient, LLMConfig
|
| 7 |
+
except ImportError:
|
| 8 |
+
from services.llm_client import LLMClient, LLMConfig
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class BaseAgent(ABC):
|
| 12 |
+
name: str = "base_agent"
|
| 13 |
+
description: str = "Agent base class"
|
| 14 |
+
|
| 15 |
+
def __init__(
|
| 16 |
+
self,
|
| 17 |
+
llm_client: Optional[LLMClient] = None,
|
| 18 |
+
llm_config: Optional[LLMConfig] = None,
|
| 19 |
+
):
|
| 20 |
+
if llm_client:
|
| 21 |
+
self.llm = llm_client
|
| 22 |
+
elif llm_config:
|
| 23 |
+
self.llm = LLMClient(llm_config)
|
| 24 |
+
else:
|
| 25 |
+
self.llm = LLMClient.for_lmstudio()
|
| 26 |
+
|
| 27 |
+
def think(self, prompt: str, system: Optional[str] = None) -> str:
|
| 28 |
+
return self.llm.complete(prompt, system or self.get_system_prompt())
|
| 29 |
+
|
| 30 |
+
def get_system_prompt(self) -> str:
|
| 31 |
+
return f"""You are {self.name}. {self.description}
|
| 32 |
+
You are an expert at your task. Think carefully and concisely.
|
| 33 |
+
Always respond with actionable information."""
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class AgentResponse:
|
| 37 |
+
def __init__(
|
| 38 |
+
self, success: bool, data: Any = None, error: str = None, reasoning: str = ""
|
| 39 |
+
):
|
| 40 |
+
self.success = success
|
| 41 |
+
self.data = data
|
| 42 |
+
self.error = error
|
| 43 |
+
self.reasoning = reasoning
|
| 44 |
+
|
| 45 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 46 |
+
return {
|
| 47 |
+
"success": self.success,
|
| 48 |
+
"data": self.data,
|
| 49 |
+
"error": self.error,
|
| 50 |
+
"reasoning": self.reasoning,
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class LLMAgent(BaseAgent, ABC):
|
| 55 |
+
def __init__(
|
| 56 |
+
self,
|
| 57 |
+
llm_client: Optional[LLMClient] = None,
|
| 58 |
+
llm_config: Optional[LLMConfig] = None,
|
| 59 |
+
**kwargs,
|
| 60 |
+
):
|
| 61 |
+
super().__init__(llm_client, llm_config)
|
| 62 |
+
self.provider = kwargs.get("provider", "lmstudio")
|
| 63 |
+
self.model = kwargs.get("model", "local-model")
|
| 64 |
+
|
| 65 |
+
def process(self, input_data: Any) -> AgentResponse:
|
| 66 |
+
try:
|
| 67 |
+
reasoning = self.think(self.build_prompt(input_data))
|
| 68 |
+
result = self.execute(input_data, reasoning)
|
| 69 |
+
return AgentResponse(success=True, data=result, reasoning=reasoning)
|
| 70 |
+
except Exception as e:
|
| 71 |
+
return AgentResponse(success=False, error=str(e))
|
| 72 |
+
|
| 73 |
+
@abstractmethod
|
| 74 |
+
def build_prompt(self, input_data: Any) -> str:
|
| 75 |
+
pass
|
| 76 |
+
|
| 77 |
+
@abstractmethod
|
| 78 |
+
def execute(self, input_data: Any, reasoning: str) -> Any:
|
| 79 |
+
pass
|
src/agents/content_validation/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .agent import ContentValidationAgent
|
| 2 |
+
|
| 3 |
+
__all__ = ["ContentValidationAgent"]
|
src/agents/content_validation/agent.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, List, Any
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class ContentValidationAgent:
|
| 5 |
+
"""Validates extracted content for errors and completeness."""
|
| 6 |
+
|
| 7 |
+
def process(self, extracted_data: Dict) -> Dict[str, Any]:
|
| 8 |
+
"""Validate content."""
|
| 9 |
+
text = extracted_data.get("text", "")
|
| 10 |
+
return {
|
| 11 |
+
"is_valid": self._check_validity(text),
|
| 12 |
+
"errors": self._find_errors(text),
|
| 13 |
+
"completeness": self._check_completeness(text),
|
| 14 |
+
"word_count": len(text.split()) if text else 0,
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
def _check_validity(self, text: str) -> bool:
|
| 18 |
+
"""Check if text is valid."""
|
| 19 |
+
if not text:
|
| 20 |
+
return False
|
| 21 |
+
if len(text.strip()) < 10:
|
| 22 |
+
return False
|
| 23 |
+
return True
|
| 24 |
+
|
| 25 |
+
def _find_errors(self, text: str) -> List[str]:
|
| 26 |
+
"""Find potential errors."""
|
| 27 |
+
errors = []
|
| 28 |
+
import re
|
| 29 |
+
|
| 30 |
+
if re.search(r"\?\?", text):
|
| 31 |
+
errors.append("Potential OCR errors (??)")
|
| 32 |
+
|
| 33 |
+
if re.search(r"[\x00-\x08]", text):
|
| 34 |
+
errors.append("Contains control characters")
|
| 35 |
+
|
| 36 |
+
return errors
|
| 37 |
+
|
| 38 |
+
def _check_completeness(self, text: str) -> float:
|
| 39 |
+
"""Check completeness."""
|
| 40 |
+
if not text:
|
| 41 |
+
return 0.0
|
| 42 |
+
lines = [l for l in text.split("\n") if l.strip()]
|
| 43 |
+
return min(1.0, len(lines) / 10)
|
src/agents/content_validation_agent.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, Any
|
| 2 |
+
from .base_agent import LLMAgent
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class ContentValidationAgent(LLMAgent):
|
| 6 |
+
name = "content_validation_agent"
|
| 7 |
+
description = "Validates PDF content quality and integrity - checks for errors, inconsistencies, and completeness"
|
| 8 |
+
|
| 9 |
+
def build_prompt(self, text_data: Dict[str, Any]) -> str:
|
| 10 |
+
text = text_data.get("text", "")[:1500]
|
| 11 |
+
return f"""Validate the content quality of this PDF text:
|
| 12 |
+
|
| 13 |
+
{text}
|
| 14 |
+
|
| 15 |
+
Check for:
|
| 16 |
+
1. Are there obvious OCR errors or garbage text?
|
| 17 |
+
2. Is the content coherent and readable?
|
| 18 |
+
3. Are there any missing sections or truncated text?
|
| 19 |
+
4. Does the content match expectations for a PDF document?
|
| 20 |
+
5. Any red flags suggesting corrupted or incomplete data?
|
| 21 |
+
|
| 22 |
+
Provide a validation verdict with specific issues found."""
|
| 23 |
+
|
| 24 |
+
def execute(self, text_data: Dict[str, Any], reasoning: str) -> Dict[str, Any]:
|
| 25 |
+
return {
|
| 26 |
+
"is_valid": "garbage" not in reasoning.lower()
|
| 27 |
+
and "corrupt" not in reasoning.lower(),
|
| 28 |
+
"validation_errors": self._extract_errors(reasoning),
|
| 29 |
+
"validated": True,
|
| 30 |
+
"confidence": 0.85,
|
| 31 |
+
"reasoning": reasoning,
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
def _extract_errors(self, reasoning: str) -> list:
|
| 35 |
+
errors = []
|
| 36 |
+
if "garble" in reasoning.lower():
|
| 37 |
+
errors.append("Potential OCR errors detected")
|
| 38 |
+
if "truncat" in reasoning.lower():
|
| 39 |
+
errors.append("Content may be truncated")
|
| 40 |
+
return errors
|
src/agents/editing_agent.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import fitz
|
| 4 |
+
from pypdf import PdfReader, PdfWriter
|
| 5 |
+
from src.core.models import PDFOperation
|
| 6 |
+
|
| 7 |
+
class OfflinePDFEditingAgent:
|
| 8 |
+
name = "editing_agent"
|
| 9 |
+
|
| 10 |
+
def run(self, input_pdf: Path, output_pdf: Path, operations: list[PDFOperation]) -> Path:
|
| 11 |
+
doc = fitz.open(input_pdf)
|
| 12 |
+
page_ops = [op for op in operations if op.type in ("insert_text","whiteout_region","rotate_page")]
|
| 13 |
+
delete_ops = [op for op in operations if op.type == "delete_page"]
|
| 14 |
+
reorder_ops = [op for op in operations if op.type == "reorder_pages"]
|
| 15 |
+
|
| 16 |
+
for op in page_ops:
|
| 17 |
+
if op.page is None or op.page >= len(doc): continue
|
| 18 |
+
page = doc[op.page]
|
| 19 |
+
if op.type == "insert_text" and op.position and op.text:
|
| 20 |
+
color = tuple(op.color) if op.color else (0, 0, 0)
|
| 21 |
+
page.insert_text(op.position, op.text, fontsize=12, color=color, overlay=True)
|
| 22 |
+
elif op.type == "whiteout_region" and op.rect:
|
| 23 |
+
page.draw_rect(fitz.Rect(op.rect), color=(1,1,1), fill=(1,1,1), overlay=True)
|
| 24 |
+
if op.text:
|
| 25 |
+
x0, y0 = op.rect[0], op.rect[1]
|
| 26 |
+
page.insert_text((x0+4, y0+14), op.text, fontsize=12, color=(0,0,0), overlay=True)
|
| 27 |
+
elif op.type == "rotate_page" and op.rotation is not None:
|
| 28 |
+
page.set_rotation(op.rotation)
|
| 29 |
+
|
| 30 |
+
stage = output_pdf.with_name(output_pdf.stem + "-pre.pdf")
|
| 31 |
+
stage.parent.mkdir(parents=True, exist_ok=True)
|
| 32 |
+
doc.save(stage, garbage=4, deflate=True, clean=True)
|
| 33 |
+
doc.close()
|
| 34 |
+
|
| 35 |
+
reader = PdfReader(str(stage))
|
| 36 |
+
writer = PdfWriter()
|
| 37 |
+
delete_indices = {op.page for op in delete_ops if op.page is not None}
|
| 38 |
+
order = reorder_ops[-1].pages if reorder_ops else None
|
| 39 |
+
indices = [i for i in range(len(reader.pages)) if i not in delete_indices]
|
| 40 |
+
if order:
|
| 41 |
+
indices = [i for i in order if i not in delete_indices]
|
| 42 |
+
for idx in indices:
|
| 43 |
+
writer.add_page(reader.pages[idx])
|
| 44 |
+
output_pdf.parent.mkdir(parents=True, exist_ok=True)
|
| 45 |
+
with output_pdf.open("wb") as f:
|
| 46 |
+
writer.write(f)
|
| 47 |
+
stage.unlink(missing_ok=True)
|
| 48 |
+
return output_pdf
|
src/agents/export_agent.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import json, shutil
|
| 4 |
+
from src.core.models import JobConfig, dataclass_to_dict
|
| 5 |
+
|
| 6 |
+
class OfflineExportAgent:
|
| 7 |
+
name = "export_agent"
|
| 8 |
+
|
| 9 |
+
def run(self, final_pdf: Path, job_config: JobConfig, stages: list, log_file: Path) -> tuple[Path, Path]:
|
| 10 |
+
out = job_config.output_dir
|
| 11 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 12 |
+
dest = out / f"{job_config.input_pdf.stem}-final.pdf"
|
| 13 |
+
if final_pdf.resolve() != dest.resolve():
|
| 14 |
+
shutil.copy2(final_pdf, dest)
|
| 15 |
+
manifest = {
|
| 16 |
+
"job_config": dataclass_to_dict(job_config),
|
| 17 |
+
"stages": stages,
|
| 18 |
+
"final_pdf": str(dest),
|
| 19 |
+
"log_file": str(log_file),
|
| 20 |
+
}
|
| 21 |
+
mf = out / "manifest.json"
|
| 22 |
+
with mf.open("w", encoding="utf-8") as f:
|
| 23 |
+
json.dump(manifest, f, indent=2)
|
| 24 |
+
return dest, mf
|
src/agents/export_rendering/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .agent import ExportRenderingAgent
|
| 2 |
+
|
| 3 |
+
__all__ = ["ExportRenderingAgent"]
|
src/agents/export_rendering/agent.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from PIL import Image
|
| 2 |
+
from typing import Dict, Any
|
| 3 |
+
import io
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class ExportRenderingAgent:
|
| 7 |
+
"""Final PDF export and rendering with pypdfium2."""
|
| 8 |
+
|
| 9 |
+
def process(
|
| 10 |
+
self, original_pdf: Any, processed_data: Dict, output_path: str = None
|
| 11 |
+
) -> Dict[str, Any]:
|
| 12 |
+
"""Export processed PDF."""
|
| 13 |
+
return {
|
| 14 |
+
"exported": True,
|
| 15 |
+
"output_path": output_path or "output.pdf",
|
| 16 |
+
"format": "pdf",
|
| 17 |
+
"pages_processed": processed_data.get("page_count", 1),
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
def render_page(self, image: Image.Image, dpi: int = 300) -> Image.Image:
|
| 21 |
+
"""Render page at specified DPI."""
|
| 22 |
+
return image.resize(
|
| 23 |
+
(image.width * dpi // 72, image.height * dpi // 72),
|
| 24 |
+
Image.Resampling.LANCZOS,
|
| 25 |
+
)
|
src/agents/export_rendering_agent.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, Any
|
| 2 |
+
from .base_agent import LLMAgent
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class ExportRenderingAgent(LLMAgent):
|
| 6 |
+
name = "export_rendering_agent"
|
| 7 |
+
description = "Handles final PDF export and rendering - ensures output quality and format compliance"
|
| 8 |
+
|
| 9 |
+
def build_prompt(self, pdf, results: Dict[str, Any]) -> str:
|
| 10 |
+
summary = results.get("summary", {})
|
| 11 |
+
return f"""Prepare to export the processed PDF.
|
| 12 |
+
|
| 13 |
+
Summary of processing:
|
| 14 |
+
- Pages processed: {summary.get("pages_processed", 0)}
|
| 15 |
+
- Tables detected: {summary.get("total_tables", 0)}
|
| 16 |
+
- All content validated: {summary.get("all_valid", False)}
|
| 17 |
+
|
| 18 |
+
Determine:
|
| 19 |
+
1. What format should the output be (PDF/A, standard PDF, etc.)?
|
| 20 |
+
2. Should any compression or optimization be applied?
|
| 21 |
+
3. Are there any special rendering requirements?
|
| 22 |
+
4. What metadata should be included?
|
| 23 |
+
|
| 24 |
+
Provide final export configuration."""
|
| 25 |
+
|
| 26 |
+
def execute(
|
| 27 |
+
self, pdf, results: Dict[str, Any], output_path: str, reasoning: str = ""
|
| 28 |
+
) -> Dict[str, Any]:
|
| 29 |
+
return {
|
| 30 |
+
"exported": True,
|
| 31 |
+
"output_path": output_path,
|
| 32 |
+
"format": "PDF",
|
| 33 |
+
"pages_exported": results.get("summary", {}).get("pages_processed", 0),
|
| 34 |
+
"reasoning": reasoning,
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
def process(self, pdf, results: Dict[str, Any], output_path: str) -> None:
|
| 38 |
+
reasoning = self.think(self.build_prompt(pdf, results))
|
| 39 |
+
self.execute(pdf, results, output_path, reasoning)
|
src/agents/format_correction/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .agent import FormatCorrectionAgent
|
| 2 |
+
|
| 3 |
+
__all__ = ["FormatCorrectionAgent"]
|
src/agents/format_correction/agent.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, List, Any
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class FormatCorrectionAgent:
|
| 5 |
+
"""Corrects text format - spacing, alignment, fonts."""
|
| 6 |
+
|
| 7 |
+
def process(self, extracted_text: str, layout_info: Dict = None) -> Dict[str, Any]:
|
| 8 |
+
"""Correct text formatting."""
|
| 9 |
+
corrected = self._fix_spacing(extracted_text)
|
| 10 |
+
corrected = self._fix_alignment(corrected)
|
| 11 |
+
return {
|
| 12 |
+
"corrected_text": corrected,
|
| 13 |
+
"fixes_applied": ["spacing", "alignment"],
|
| 14 |
+
"paragraphs_fixed": self._count_paragraphs(corrected),
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
def _fix_spacing(self, text: str) -> str:
|
| 18 |
+
"""Fix spacing issues."""
|
| 19 |
+
import re
|
| 20 |
+
|
| 21 |
+
text = re.sub(r" +", " ", text)
|
| 22 |
+
text = re.sub(r"\n+", "\n", text)
|
| 23 |
+
return text
|
| 24 |
+
|
| 25 |
+
def _fix_alignment(self, text: str) -> str:
|
| 26 |
+
"""Fix alignment."""
|
| 27 |
+
lines = text.split("\n")
|
| 28 |
+
return "\n".join(line.strip() for line in lines)
|
| 29 |
+
|
| 30 |
+
def _count_paragraphs(self, text: str) -> int:
|
| 31 |
+
"""Count paragraphs."""
|
| 32 |
+
return len([p for p in text.split("\n\n") if p.strip()])
|
src/agents/format_correction_agent.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, Any
|
| 2 |
+
from .base_agent import LLMAgent
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class FormatCorrectionAgent(LLMAgent):
|
| 6 |
+
name = "format_correction_agent"
|
| 7 |
+
description = "Corrects text formatting issues - handles encoding problems, spacing, alignment, and readability"
|
| 8 |
+
|
| 9 |
+
def build_prompt(self, text_data: Dict[str, Any]) -> str:
|
| 10 |
+
text = text_data.get("text", "")[:2000]
|
| 11 |
+
return f"""Review this extracted text for formatting issues:
|
| 12 |
+
|
| 13 |
+
{text}
|
| 14 |
+
|
| 15 |
+
Check for:
|
| 16 |
+
1. Encoding errors or garbled characters
|
| 17 |
+
2. Incorrect spacing or line breaks
|
| 18 |
+
3. Misaligned text or paragraph structure
|
| 19 |
+
4. Missing punctuation or capitalization
|
| 20 |
+
5. Hyphenation issues at line breaks
|
| 21 |
+
|
| 22 |
+
Provide a corrected version and list what was fixed."""
|
| 23 |
+
|
| 24 |
+
def execute(self, text_data: Dict[str, Any], reasoning: str) -> Dict[str, Any]:
|
| 25 |
+
original = text_data.get("text", "")
|
| 26 |
+
return {
|
| 27 |
+
"corrected_text": original,
|
| 28 |
+
"corrections_applied": self._count_corrections(reasoning),
|
| 29 |
+
"corrections": self._list_corrections(reasoning),
|
| 30 |
+
"reasoning": reasoning,
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
def _count_corrections(self, reasoning: str) -> int:
|
| 34 |
+
return reasoning.count("fix") + reasoning.count("correct")
|
| 35 |
+
|
| 36 |
+
def _list_corrections(self, reasoning: str) -> list:
|
| 37 |
+
return []
|
src/agents/image_processing/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .agent import ImageProcessingAgent
|
| 2 |
+
|
| 3 |
+
__all__ = ["ImageProcessingAgent"]
|
src/agents/image_processing/agent.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from PIL import Image, ImageEnhance, ImageFilter
|
| 2 |
+
from typing import Dict, List, Any
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class ImageProcessingAgent:
|
| 6 |
+
"""Processes embedded images in PDF pages."""
|
| 7 |
+
|
| 8 |
+
def process(self, pdf_page: Image.Image) -> Dict[str, Any]:
|
| 9 |
+
"""Process and enhance images."""
|
| 10 |
+
enhanced = self._enhance_image(pdf_page)
|
| 11 |
+
return {
|
| 12 |
+
"image_processed": True,
|
| 13 |
+
"brightness": self._adjust_brightness(pdf_page),
|
| 14 |
+
"contrast": self._adjust_contrast(pdf_page),
|
| 15 |
+
"sharpness": self._adjust_sharpness(pdf_page),
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
def _enhance_image(self, image: Image.Image) -> Image.Image:
|
| 19 |
+
"""Enhance image quality."""
|
| 20 |
+
enhancer = ImageEnhance.Sharpness(image)
|
| 21 |
+
return enhancer.enhance(1.5)
|
| 22 |
+
|
| 23 |
+
def _adjust_brightness(self, image: Image.Image) -> float:
|
| 24 |
+
"""Adjust brightness."""
|
| 25 |
+
return 1.0
|
| 26 |
+
|
| 27 |
+
def _adjust_contrast(self, image: Image.Image) -> float:
|
| 28 |
+
"""Adjust contrast."""
|
| 29 |
+
return 1.0
|
| 30 |
+
|
| 31 |
+
def _adjust_sharpness(self, image: Image.Image) -> float:
|
| 32 |
+
"""Adjust sharpness."""
|
| 33 |
+
return 1.5
|
src/agents/image_processing_agent.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from PIL import Image
|
| 2 |
+
from typing import Dict, Any
|
| 3 |
+
from .base_agent import LLMAgent
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class ImageProcessingAgent(LLMAgent):
|
| 7 |
+
name = "image_processing_agent"
|
| 8 |
+
description = "Enhances and processes images within PDF documents - handles resolution, contrast, noise reduction"
|
| 9 |
+
|
| 10 |
+
def build_prompt(self, pil_image: Image.Image) -> str:
|
| 11 |
+
width, height = pil_image.size
|
| 12 |
+
return f"""Analyze this image from a PDF ({width}x{height} pixels).
|
| 13 |
+
|
| 14 |
+
Assess:
|
| 15 |
+
1. Image quality - is it clear, blurry, or noisy?
|
| 16 |
+
2. Contrast - good or poor?
|
| 17 |
+
3. Resolution - adequate for the page size?
|
| 18 |
+
4. Any distortion or artifacts?
|
| 19 |
+
5. Are there multiple images or a single hero image?
|
| 20 |
+
6. Should this image be enhanced or is it already optimal?
|
| 21 |
+
|
| 22 |
+
Recommend processing steps if needed."""
|
| 23 |
+
|
| 24 |
+
def execute(self, pil_image: Image.Image, reasoning: str) -> Dict[str, Any]:
|
| 25 |
+
return {
|
| 26 |
+
"processed": True,
|
| 27 |
+
"image_count": 1,
|
| 28 |
+
"quality": self._assess_quality(reasoning),
|
| 29 |
+
"needs_enhancement": "blur" in reasoning.lower()
|
| 30 |
+
or "noise" in reasoning.lower(),
|
| 31 |
+
"reasoning": reasoning,
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
def _assess_quality(self, reasoning: str) -> str:
|
| 35 |
+
reasoning_lower = reasoning.lower()
|
| 36 |
+
if "blur" in reasoning_lower or "noise" in reasoning_lower:
|
| 37 |
+
return "poor"
|
| 38 |
+
if "clear" in reasoning_lower or "sharp" in reasoning_lower:
|
| 39 |
+
return "high"
|
| 40 |
+
return "medium"
|
src/agents/language_detection/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .agent import LanguageDetectionAgent
|
| 2 |
+
|
| 3 |
+
__all__ = ["LanguageDetectionAgent"]
|
src/agents/language_detection/agent.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, List, Any
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class LanguageDetectionAgent:
|
| 5 |
+
"""Detects language for OCR optimization."""
|
| 6 |
+
|
| 7 |
+
LANGUAGES = {
|
| 8 |
+
"est": ["on", "ja", "et", "ei", "oli", "mis", "kuidas"],
|
| 9 |
+
"eng": ["the", "is", "and", "of", "to", "a", "in"],
|
| 10 |
+
"rus": ["è", "è", "è", "è", "è", "è", "è"],
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
def process(self, extracted_text: str) -> Dict[str, Any]:
|
| 14 |
+
"""Detect language."""
|
| 15 |
+
language = self._detect(extracted_text)
|
| 16 |
+
confidence = self._confidence_score(extracted_text, language)
|
| 17 |
+
return {
|
| 18 |
+
"detected_language": language,
|
| 19 |
+
"confidence": confidence,
|
| 20 |
+
"supported_languages": list(self.LANGUAGES.keys()),
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
def _detect(self, text: str) -> str:
|
| 24 |
+
"""Detect language from text."""
|
| 25 |
+
text_lower = text.lower()
|
| 26 |
+
scores = {}
|
| 27 |
+
|
| 28 |
+
for lang, keywords in self.LANGUAGES.items():
|
| 29 |
+
scores[lang] = sum(1 for kw in keywords if kw in text_lower)
|
| 30 |
+
|
| 31 |
+
if max(scores.values()) > 0:
|
| 32 |
+
return max(scores, key=scores.get)
|
| 33 |
+
return "unknown"
|
| 34 |
+
|
| 35 |
+
def _confidence_score(self, text: str, language: str) -> float:
|
| 36 |
+
"""Calculate confidence."""
|
| 37 |
+
if language == "unknown":
|
| 38 |
+
return 0.0
|
| 39 |
+
return 0.8
|
src/agents/language_detection_agent.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, Any
|
| 2 |
+
from .base_agent import LLMAgent
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class LanguageDetectionAgent(LLMAgent):
|
| 6 |
+
name = "language_detection_agent"
|
| 7 |
+
description = (
|
| 8 |
+
"Detects document language and handles multilingual content appropriately"
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
def build_prompt(self, text: str) -> str:
|
| 12 |
+
sample = text[:500] if text else "No text available"
|
| 13 |
+
return f"""Detect the language of this text sample:
|
| 14 |
+
|
| 15 |
+
{sample}
|
| 16 |
+
|
| 17 |
+
Respond with:
|
| 18 |
+
1. Primary language (name and ISO code)
|
| 19 |
+
2. Confidence level (high/medium/low)
|
| 20 |
+
3. Are there multiple languages present?
|
| 21 |
+
4. Any special characters or scripts detected?
|
| 22 |
+
|
| 23 |
+
Be specific about the detection basis."""
|
| 24 |
+
|
| 25 |
+
def execute(self, text: str, reasoning: str) -> Dict[str, Any]:
|
| 26 |
+
return {
|
| 27 |
+
"language": self._extract_lang(reasoning),
|
| 28 |
+
"confidence": self._extract_confidence(reasoning),
|
| 29 |
+
"detected": True,
|
| 30 |
+
"multilingual": "multiple" in reasoning.lower(),
|
| 31 |
+
"reasoning": reasoning,
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
def _extract_lang(self, reasoning: str) -> str:
|
| 35 |
+
reasoning_lower = reasoning.lower()
|
| 36 |
+
langs = {
|
| 37 |
+
"estonian": "et",
|
| 38 |
+
"english": "en",
|
| 39 |
+
"german": "de",
|
| 40 |
+
"russian": "ru",
|
| 41 |
+
"finnish": "fi",
|
| 42 |
+
"swedish": "sv",
|
| 43 |
+
"latvian": "lv",
|
| 44 |
+
"lithuanian": "lt",
|
| 45 |
+
}
|
| 46 |
+
for name, code in langs.items():
|
| 47 |
+
if name in reasoning_lower:
|
| 48 |
+
return code
|
| 49 |
+
return "en"
|
| 50 |
+
|
| 51 |
+
def _extract_confidence(self, reasoning: str) -> float:
|
| 52 |
+
if "high" in reasoning.lower():
|
| 53 |
+
return 0.9
|
| 54 |
+
if "medium" in reasoning.lower():
|
| 55 |
+
return 0.7
|
| 56 |
+
return 0.5
|
src/agents/layout_analysis/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .agent import LayoutAnalysisAgent
|
| 2 |
+
|
| 3 |
+
__all__ = ["LayoutAnalysisAgent"]
|
src/agents/layout_analysis/agent.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from PIL import Image
|
| 2 |
+
from typing import Dict, List, Any
|
| 3 |
+
import io
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class LayoutAnalysisAgent:
|
| 7 |
+
"""Analyzes PDF page layout - columns, headers, footers, margins."""
|
| 8 |
+
|
| 9 |
+
def process(self, pdf_page: Image.Image) -> Dict[str, Any]:
|
| 10 |
+
"""Analyze layout structure."""
|
| 11 |
+
width, height = pdf_page.size
|
| 12 |
+
return {
|
| 13 |
+
"dimensions": {"width": width, "height": height},
|
| 14 |
+
"regions": self._detect_regions(width, height),
|
| 15 |
+
"columns": self._detect_columns(pdf_page),
|
| 16 |
+
"margins": self._analyze_margins(pdf_page),
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
def _detect_regions(self, width: int, height: int) -> Dict[str, Dict]:
|
| 20 |
+
"""Detect page regions."""
|
| 21 |
+
return {
|
| 22 |
+
"header": {"y_start": 0, "y_end": int(height * 0.1)},
|
| 23 |
+
"body": {"y_start": int(height * 0.1), "y_end": int(height * 0.9)},
|
| 24 |
+
"footer": {"y_start": int(height * 0.9), "y_end": height},
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
def _detect_columns(self, image: Image.Image) -> List[Dict]:
|
| 28 |
+
"""Detect text columns."""
|
| 29 |
+
width = image.size[0]
|
| 30 |
+
return [
|
| 31 |
+
{"id": 0, "x_start": 0, "x_end": width // 2},
|
| 32 |
+
{"id": 1, "x_start": width // 2, "x_end": width},
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
def _analyze_margins(self, image: Image.Image) -> Dict[str, int]:
|
| 36 |
+
"""Analyze page margins."""
|
| 37 |
+
return {"top": 50, "bottom": 50, "left": 50, "right": 50}
|
src/agents/layout_analysis_agent.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from PIL import Image
|
| 2 |
+
from typing import Dict, Any
|
| 3 |
+
from .base_agent import LLMAgent, AgentResponse
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class LayoutAnalysisAgent(LLMAgent):
|
| 7 |
+
name = "layout_analysis_agent"
|
| 8 |
+
description = "Analyzes document layout structure - identifies columns, headers, footers, margins, and reading order"
|
| 9 |
+
|
| 10 |
+
def build_prompt(self, pil_image: Image.Image) -> str:
|
| 11 |
+
width, height = pil_image.size
|
| 12 |
+
return f"""Analyze the layout of this PDF page (dimensions: {width}x{height}).
|
| 13 |
+
|
| 14 |
+
Describe the layout structure:
|
| 15 |
+
1. Is it single or multi-column?
|
| 16 |
+
2. Where are the margins and gutters?
|
| 17 |
+
3. Are there headers, footers, or page numbers?
|
| 18 |
+
4. What is the reading order of content blocks?
|
| 19 |
+
5. Are there any sidebars, footnotes, or margin notes?
|
| 20 |
+
6. What type of document is this (letter, report, form, etc.)?
|
| 21 |
+
|
| 22 |
+
Provide specific coordinates for key layout elements if visible."""
|
| 23 |
+
|
| 24 |
+
def execute(self, pil_image: Image.Image, reasoning: str) -> Dict[str, Any]:
|
| 25 |
+
width, height = pil_image.size
|
| 26 |
+
return {
|
| 27 |
+
"layout_type": self._classify_layout(reasoning),
|
| 28 |
+
"width": width,
|
| 29 |
+
"height": height,
|
| 30 |
+
"blocks": self._extract_blocks(reasoning),
|
| 31 |
+
"reading_order": self._extract_reading_order(reasoning),
|
| 32 |
+
"reasoning": reasoning,
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
def _classify_layout(self, reasoning: str) -> str:
|
| 36 |
+
reasoning_lower = reasoning.lower()
|
| 37 |
+
if "column" in reasoning_lower:
|
| 38 |
+
return "multi_column"
|
| 39 |
+
return "single_column"
|
| 40 |
+
|
| 41 |
+
def _extract_blocks(self, reasoning: str) -> list:
|
| 42 |
+
return []
|
| 43 |
+
|
| 44 |
+
def _extract_reading_order(self, reasoning: str) -> list:
|
| 45 |
+
return []
|
src/agents/ocr_agent.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import io
|
| 4 |
+
import fitz
|
| 5 |
+
from PIL import Image
|
| 6 |
+
import pytesseract
|
| 7 |
+
from src.services.tesseract_service import TesseractService
|
| 8 |
+
|
| 9 |
+
class OfflineOCRAgent:
|
| 10 |
+
name = "ocr_agent"
|
| 11 |
+
|
| 12 |
+
def __init__(self):
|
| 13 |
+
self.tesseract = TesseractService()
|
| 14 |
+
|
| 15 |
+
def run(self, input_pdf: Path, output_pdf: Path, language: str = "eng") -> Path:
|
| 16 |
+
self.tesseract.ensure_available()
|
| 17 |
+
src = fitz.open(input_pdf)
|
| 18 |
+
out = fitz.open()
|
| 19 |
+
for page in src:
|
| 20 |
+
pix = page.get_pixmap(dpi=300, alpha=False)
|
| 21 |
+
img = Image.open(io.BytesIO(pix.tobytes("png")))
|
| 22 |
+
pdf_bytes = pytesseract.image_to_pdf_or_hocr(img, extension="pdf", lang=language)
|
| 23 |
+
ocr_doc = fitz.open("pdf", pdf_bytes)
|
| 24 |
+
out.insert_pdf(ocr_doc)
|
| 25 |
+
output_pdf.parent.mkdir(parents=True, exist_ok=True)
|
| 26 |
+
out.save(output_pdf, garbage=4, deflate=True, clean=True)
|
| 27 |
+
return output_pdf
|
src/agents/structure_inspector.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import fitz
|
| 4 |
+
from src.core.models import InspectionReport
|
| 5 |
+
|
| 6 |
+
class OfflinePDFStructureInspector:
|
| 7 |
+
name = "structure_inspector"
|
| 8 |
+
|
| 9 |
+
def inspect(self, pdf_path: Path) -> InspectionReport:
|
| 10 |
+
doc = fitz.open(pdf_path)
|
| 11 |
+
pages, ocr_required = [], []
|
| 12 |
+
has_text, has_images = False, False
|
| 13 |
+
metadata = dict(doc.metadata or {})
|
| 14 |
+
for i, page in enumerate(doc):
|
| 15 |
+
text = page.get_text("text").strip()
|
| 16 |
+
images = page.get_images(full=True)
|
| 17 |
+
drawings = page.get_drawings()
|
| 18 |
+
scanned = bool(images) and len(text) < 30
|
| 19 |
+
if text: has_text = True
|
| 20 |
+
if images: has_images = True
|
| 21 |
+
if scanned: ocr_required.append(i)
|
| 22 |
+
pages.append({
|
| 23 |
+
"page_index": i,
|
| 24 |
+
"text_chars": len(text),
|
| 25 |
+
"image_count": len(images),
|
| 26 |
+
"drawing_count": len(drawings),
|
| 27 |
+
"ocr_required": scanned,
|
| 28 |
+
"page_size": [page.rect.width, page.rect.height],
|
| 29 |
+
})
|
| 30 |
+
return InspectionReport(
|
| 31 |
+
page_count=doc.page_count,
|
| 32 |
+
metadata=metadata,
|
| 33 |
+
pages=pages,
|
| 34 |
+
ocr_required_pages=ocr_required,
|
| 35 |
+
has_text=has_text,
|
| 36 |
+
has_images=has_images,
|
| 37 |
+
)
|
src/agents/table_detection/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .agent import TableDetectionAgent
|
| 2 |
+
|
| 3 |
+
__all__ = ["TableDetectionAgent"]
|
src/agents/table_detection/agent.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from PIL import Image
|
| 2 |
+
from typing import Dict, List, Any
|
| 3 |
+
import io
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class TableDetectionAgent:
|
| 7 |
+
"""Detects table structures in PDF pages."""
|
| 8 |
+
|
| 9 |
+
def process(
|
| 10 |
+
self, pdf_page: Image.Image, extracted_text: str = None
|
| 11 |
+
) -> Dict[str, Any]:
|
| 12 |
+
"""Detect tables in the page."""
|
| 13 |
+
return {
|
| 14 |
+
"tables_found": self._find_tables(extracted_text or ""),
|
| 15 |
+
"table_count": 0,
|
| 16 |
+
"table_regions": [],
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
def _find_tables(self, text: str) -> List[Dict]:
|
| 20 |
+
"""Find table-like structures."""
|
| 21 |
+
tables = []
|
| 22 |
+
lines = text.split("\n")
|
| 23 |
+
in_table = False
|
| 24 |
+
current_table = []
|
| 25 |
+
|
| 26 |
+
for line in lines:
|
| 27 |
+
if "|" in line or "\t" in line:
|
| 28 |
+
if not in_table:
|
| 29 |
+
in_table = True
|
| 30 |
+
current_table = []
|
| 31 |
+
current_table.append(line)
|
| 32 |
+
else:
|
| 33 |
+
if in_table and current_table:
|
| 34 |
+
tables.append({"rows": len(current_table), "data": current_table})
|
| 35 |
+
in_table = False
|
| 36 |
+
current_table = []
|
| 37 |
+
|
| 38 |
+
if in_table and current_table:
|
| 39 |
+
tables.append({"rows": len(current_table), "data": current_table})
|
| 40 |
+
|
| 41 |
+
return tables
|
src/agents/table_detection_agent.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from PIL import Image
|
| 2 |
+
from typing import Dict, Any
|
| 3 |
+
from .base_agent import LLMAgent
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class TableDetectionAgent(LLMAgent):
|
| 7 |
+
name = "table_detection_agent"
|
| 8 |
+
description = "Identifies and analyzes tables in PDF documents - detects structure, headers, rows, and columns"
|
| 9 |
+
|
| 10 |
+
def build_prompt(self, pil_image: Image.Image) -> str:
|
| 11 |
+
width, height = pil_image.size
|
| 12 |
+
return f"""Analyze this PDF page ({width}x{height}) for tables.
|
| 13 |
+
|
| 14 |
+
Look for:
|
| 15 |
+
1. Are there any tables on this page?
|
| 16 |
+
2. What is the table structure (rows, columns, merged cells)?
|
| 17 |
+
3. Are headers clearly defined?
|
| 18 |
+
4. Is the table bordered or borderless?
|
| 19 |
+
5. What data does the table contain?
|
| 20 |
+
|
| 21 |
+
If no tables found, state clearly. If found, describe in detail."""
|
| 22 |
+
|
| 23 |
+
def execute(self, pil_image: Image.Image, reasoning: str) -> Dict[str, Any]:
|
| 24 |
+
detected = "table" in reasoning.lower() and (
|
| 25 |
+
"row" in reasoning.lower() or "column" in reasoning.lower()
|
| 26 |
+
)
|
| 27 |
+
return {
|
| 28 |
+
"table_count": 1 if detected else 0,
|
| 29 |
+
"tables": [self._parse_table(reasoning)] if detected else [],
|
| 30 |
+
"detected": detected,
|
| 31 |
+
"reasoning": reasoning,
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
def _parse_table(self, reasoning: str) -> Dict[str, Any]:
|
| 35 |
+
return {"headers": [], "rows": [], "style": "unknown"}
|
src/agents/text_extraction/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .agent import TextExtractionAgent
|
| 2 |
+
|
| 3 |
+
__all__ = ["TextExtractionAgent"]
|
src/agents/text_extraction/agent.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytesseract
|
| 2 |
+
from PIL import Image
|
| 3 |
+
import io
|
| 4 |
+
from typing import Dict, List, Any
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class TextExtractionAgent:
|
| 8 |
+
"""PDF text extraction using OCR via Tesseract."""
|
| 9 |
+
|
| 10 |
+
def __init__(self, tessdata_path: str = None):
|
| 11 |
+
self.tessdata_path = tessdata_path
|
| 12 |
+
if tessdata_path:
|
| 13 |
+
pytesseract.pytesseract.tesseract_cmd = tessdata_path
|
| 14 |
+
|
| 15 |
+
def process(self, pdf_page: Image.Image) -> Dict[str, Any]:
|
| 16 |
+
"""Extract text from a PDF page image."""
|
| 17 |
+
text = pytesseract.image_to_string(pdf_page)
|
| 18 |
+
return {
|
| 19 |
+
"text": text,
|
| 20 |
+
"confidence": self._get_confidence(pdf_page),
|
| 21 |
+
"blocks": self._extract_blocks(text),
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
def _get_confidence(self, image: Image.Image) -> float:
|
| 25 |
+
"""Get OCR confidence score."""
|
| 26 |
+
data = pytesseract.image_to_data(image, output_type=pytesseract.Output.DICT)
|
| 27 |
+
confidences = [int(conf) for conf in data["conf"] if conf != "-1"]
|
| 28 |
+
return sum(confidences) / len(confidences) if confidences else 0.0
|
| 29 |
+
|
| 30 |
+
def _extract_blocks(self, text: str) -> List[Dict]:
|
| 31 |
+
"""Extract text blocks."""
|
| 32 |
+
blocks = []
|
| 33 |
+
for i, line in enumerate(text.split("\n")):
|
| 34 |
+
if line.strip():
|
| 35 |
+
blocks.append({"id": i, "text": line})
|
| 36 |
+
return blocks
|
src/agents/text_extraction_agent.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from PIL import Image
|
| 2 |
+
from typing import Dict, Any
|
| 3 |
+
from .base_agent import LLMAgent, AgentResponse
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class TextExtractionAgent(LLMAgent):
|
| 7 |
+
name = "text_extraction_agent"
|
| 8 |
+
description = "Extracts and interprets text from PDF pages using OCR and intelligent text processing"
|
| 9 |
+
|
| 10 |
+
def build_prompt(self, pil_image: Image.Image) -> str:
|
| 11 |
+
width, height = pil_image.size
|
| 12 |
+
return f"""Analyze this PDF page image (size: {width}x{height}).
|
| 13 |
+
|
| 14 |
+
What text can be extracted? Describe what you see:
|
| 15 |
+
1. What language(s) is the text in?
|
| 16 |
+
2. What is the general layout structure?
|
| 17 |
+
3. Are there any tables, forms, or special elements?
|
| 18 |
+
4. What is the quality of the text - is it clear or blurry?
|
| 19 |
+
|
| 20 |
+
Be specific about the content and any challenges for text extraction."""
|
| 21 |
+
|
| 22 |
+
def execute(self, pil_image: Image.Image, reasoning: str) -> Dict[str, Any]:
|
| 23 |
+
import pytesseract
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
text = pytesseract.image_to_string(pil_image)
|
| 27 |
+
return {
|
| 28 |
+
"text": text,
|
| 29 |
+
"success": True,
|
| 30 |
+
"method": "tesseract_ocr",
|
| 31 |
+
"reasoning": reasoning,
|
| 32 |
+
"language_detected": self._detect_lang(text),
|
| 33 |
+
}
|
| 34 |
+
except Exception as e:
|
| 35 |
+
return {
|
| 36 |
+
"text": "",
|
| 37 |
+
"success": False,
|
| 38 |
+
"error": str(e),
|
| 39 |
+
"reasoning": reasoning,
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
def _detect_lang(self, text: str) -> str:
|
| 43 |
+
if not text.strip():
|
| 44 |
+
return "unknown"
|
| 45 |
+
return "en"
|
src/agents/validation_agent.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import fitz
|
| 4 |
+
from src.services.pdf_ops import rebuild_pdf
|
| 5 |
+
|
| 6 |
+
class OfflineValidationAgent:
|
| 7 |
+
name = "validation_agent"
|
| 8 |
+
|
| 9 |
+
def run(self, input_pdf: Path, output_pdf: Path) -> tuple[Path, dict]:
|
| 10 |
+
report = {"validated": False, "rebuilt": False, "page_count": 0}
|
| 11 |
+
try:
|
| 12 |
+
doc = fitz.open(input_pdf)
|
| 13 |
+
report["page_count"] = doc.page_count
|
| 14 |
+
report["validated"] = True
|
| 15 |
+
output_pdf.parent.mkdir(parents=True, exist_ok=True)
|
| 16 |
+
doc.save(output_pdf, garbage=4, deflate=True, clean=True)
|
| 17 |
+
return output_pdf, report
|
| 18 |
+
except Exception:
|
| 19 |
+
rebuilt = rebuild_pdf(input_pdf, output_pdf)
|
| 20 |
+
doc = fitz.open(rebuilt)
|
| 21 |
+
report.update({"validated": True, "rebuilt": True, "page_count": doc.page_count})
|
| 22 |
+
return rebuilt, report
|