HowdenParser 4.0.3__tar.gz → 4.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- howdenparser-4.2.0/HowdenParser/__init__.py +8 -0
- howdenparser-4.2.0/HowdenParser/parameter/__init__.py +2 -0
- howdenparser-4.2.0/HowdenParser/parser.py +165 -0
- howdenparser-4.2.0/HowdenParser/parsers/__init__.py +0 -0
- howdenparser-4.2.0/HowdenParser/parsers/langchain_parser.py +18 -0
- howdenparser-4.2.0/HowdenParser/parsers/llama_parser.py +61 -0
- howdenparser-4.2.0/HowdenParser/parsers/mistral_parser.py +43 -0
- {howdenparser-4.0.3 → howdenparser-4.2.0}/PKG-INFO +1 -11
- {howdenparser-4.0.3 → howdenparser-4.2.0}/pyproject.toml +8 -14
- howdenparser-4.0.3/HowdenParser/__init__.py +0 -3
- howdenparser-4.0.3/HowdenParser/parameter/__init__.py +0 -3
- howdenparser-4.0.3/HowdenParser/parameter/huggingface.py +0 -6
- howdenparser-4.0.3/HowdenParser/parser.py +0 -304
- {howdenparser-4.0.3 → howdenparser-4.2.0}/HowdenParser/parameter/llamaparser.py +0 -0
- {howdenparser-4.0.3 → howdenparser-4.2.0}/HowdenParser/parameter/mistralocr.py +0 -0
- {howdenparser-4.0.3 → howdenparser-4.2.0}/README.md +0 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from .parser import Parser
|
|
2
|
+
|
|
3
|
+
# Import all parsers to trigger registration (side effect imports)
|
|
4
|
+
from .parsers.llama_parser import LlamaParser # noqa: F401
|
|
5
|
+
from .parsers.mistral_parser import MistralOCRParser # noqa: F401
|
|
6
|
+
from .parsers.langchain_parser import LangChainParser # noqa: F401
|
|
7
|
+
|
|
8
|
+
__all__ = ["Parser"]
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
import logging
|
|
3
|
+
import dotenv
|
|
4
|
+
from typing import overload, Literal
|
|
5
|
+
from inspect import signature, Signature
|
|
6
|
+
|
|
7
|
+
dotenv.load_dotenv()
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BaseParser(ABC):
|
|
12
|
+
_registry: dict[str, type["BaseParser"]] = {}
|
|
13
|
+
|
|
14
|
+
def __init__(self):
|
|
15
|
+
# Every instance gets a .name attribute
|
|
16
|
+
self.name = getattr(self.__class__, "_parser_name", self.__class__.__name__)
|
|
17
|
+
|
|
18
|
+
def __init_subclass__(cls, name: str | None = None, **kwargs):
|
|
19
|
+
"""Automatically register subclasses under a key."""
|
|
20
|
+
super().__init_subclass__(**kwargs)
|
|
21
|
+
key = name or cls.__name__.lower().replace("parser", "")
|
|
22
|
+
BaseParser._registry[key] = cls
|
|
23
|
+
BaseParser._registry.pop("", None)
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def parse(self, text: str, include_pagenumbers: bool = False):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Parser(BaseParser):
|
|
31
|
+
"""Factory + registry interface for all parsers."""
|
|
32
|
+
|
|
33
|
+
def __new__(cls, config_or_dict: "Parameter | dict | str" = None, **kwargs) -> BaseParser:
|
|
34
|
+
"""
|
|
35
|
+
Factory entrypoint.
|
|
36
|
+
Allows calling Parser(...) directly to create the correct subclass.
|
|
37
|
+
"""
|
|
38
|
+
return cls._create(config_or_dict, **kwargs)
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def available_parsers(cls) -> None:
|
|
42
|
+
"""Print registered parsers and their init arguments."""
|
|
43
|
+
import inspect
|
|
44
|
+
result = {}
|
|
45
|
+
for name, parser_cls in BaseParser._registry.items():
|
|
46
|
+
sig = inspect.signature(parser_cls.__init__)
|
|
47
|
+
result[name] = [p for p in sig.parameters if p != "self"]
|
|
48
|
+
result.pop("", None)
|
|
49
|
+
for key, values in result.items():
|
|
50
|
+
print(f"{key} with parameters: {values}")
|
|
51
|
+
|
|
52
|
+
# ---- Overloads for IDE autocomplete ----
|
|
53
|
+
@overload
|
|
54
|
+
@classmethod
|
|
55
|
+
def _create(
|
|
56
|
+
cls,
|
|
57
|
+
*,
|
|
58
|
+
provider_and_model: Literal["llamaparser:"],
|
|
59
|
+
result_type: str,
|
|
60
|
+
mode: bool,
|
|
61
|
+
preserve_layout_alignment_across_pages: bool,
|
|
62
|
+
merge_tables_across_pages_in_markdown: bool,
|
|
63
|
+
hide_footers: bool,
|
|
64
|
+
hide_headers: bool,
|
|
65
|
+
) -> "LlamaParser": ...
|
|
66
|
+
|
|
67
|
+
@overload
|
|
68
|
+
@classmethod
|
|
69
|
+
def _create(
|
|
70
|
+
cls,
|
|
71
|
+
*,
|
|
72
|
+
provider_and_model: Literal["mistralocr:ocr-large", "mistralocr:ocr-small"],
|
|
73
|
+
) -> "MistralOCRParser": ...
|
|
74
|
+
|
|
75
|
+
@overload
|
|
76
|
+
@classmethod
|
|
77
|
+
def _create(
|
|
78
|
+
cls,
|
|
79
|
+
*,
|
|
80
|
+
provider_and_model: Literal["langchain:gpt-3.5-turbo", "langchain:gpt-4"],
|
|
81
|
+
) -> "LangChainParser": ...
|
|
82
|
+
|
|
83
|
+
# ---- Implementation ----
|
|
84
|
+
@classmethod
|
|
85
|
+
def _create(cls, config_or_dict: "Parameter | dict | str" = None, **kwargs) -> BaseParser:
|
|
86
|
+
"""
|
|
87
|
+
Dynamically create parser instances.
|
|
88
|
+
Supports: Parameter, dict, str (provider_and_model), or kwargs.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
if config_or_dict is None:
|
|
92
|
+
config_dict = {}
|
|
93
|
+
elif hasattr(config_or_dict, "model_dump"):
|
|
94
|
+
config_dict = config_or_dict.model_dump()
|
|
95
|
+
elif isinstance(config_or_dict, dict):
|
|
96
|
+
config_dict = config_or_dict
|
|
97
|
+
elif isinstance(config_or_dict, str): # shorthand
|
|
98
|
+
config_dict = {"provider_and_model": config_or_dict}
|
|
99
|
+
else:
|
|
100
|
+
raise TypeError("Expected Parameter instance, dict, str, or None for config_or_dict")
|
|
101
|
+
|
|
102
|
+
merged_args = {**config_dict, **kwargs}
|
|
103
|
+
|
|
104
|
+
if "provider_and_model" not in merged_args:
|
|
105
|
+
raise ValueError("provider_and_model must be specified, e.g. 'llamaparser:'")
|
|
106
|
+
|
|
107
|
+
provider_and_model = str(merged_args.get("provider_and_model")).strip()
|
|
108
|
+
|
|
109
|
+
if ":" not in provider_and_model:
|
|
110
|
+
raise ValueError("provider_and_model must include a colon, e.g. 'llamaparser:'")
|
|
111
|
+
|
|
112
|
+
provider, model = provider_and_model.split(":", 1)
|
|
113
|
+
provider = (provider or "").strip().lower()
|
|
114
|
+
model = (model or "").strip().lower()
|
|
115
|
+
|
|
116
|
+
if not provider:
|
|
117
|
+
raise ValueError(f"Invalid provider_and_model '{provider_and_model}': provider part is empty.")
|
|
118
|
+
|
|
119
|
+
if provider not in BaseParser._registry:
|
|
120
|
+
raise ValueError(f"Unknown parser '{provider}'. Available: {list(BaseParser._registry)}")
|
|
121
|
+
|
|
122
|
+
parser_cls = BaseParser._registry[provider]
|
|
123
|
+
|
|
124
|
+
# --- Filter valid constructor args ---
|
|
125
|
+
sig = signature(parser_cls.__init__)
|
|
126
|
+
valid_args = {k: v for k, v in merged_args.items() if k in sig.parameters and k != "self"}
|
|
127
|
+
|
|
128
|
+
# Auto-fill common args
|
|
129
|
+
if "model" in sig.parameters and "model" not in valid_args:
|
|
130
|
+
valid_args["model"] = model
|
|
131
|
+
if "provider_and_model" in sig.parameters and "provider_and_model" not in valid_args:
|
|
132
|
+
valid_args["provider_and_model"] = provider_and_model
|
|
133
|
+
|
|
134
|
+
# --- Check required args ---
|
|
135
|
+
required_params = [
|
|
136
|
+
p.name
|
|
137
|
+
for p in sig.parameters.values()
|
|
138
|
+
if p.name != "self"
|
|
139
|
+
and p.default is Signature.empty
|
|
140
|
+
and p.kind in (p.POSITIONAL_OR_KEYWORD, p.KEYWORD_ONLY)
|
|
141
|
+
]
|
|
142
|
+
missing = [name for name in required_params if name not in valid_args]
|
|
143
|
+
|
|
144
|
+
# Special rule: for llamaparser we allow empty `model`
|
|
145
|
+
if provider != "llamaparser" and not model:
|
|
146
|
+
missing.append("model")
|
|
147
|
+
|
|
148
|
+
if missing:
|
|
149
|
+
example = ""
|
|
150
|
+
if provider == "llamaparser":
|
|
151
|
+
example = "Example: Parser('llamaparser:', result_type='md', mode=True, extract_tables=True)"
|
|
152
|
+
elif provider == "mistralocr":
|
|
153
|
+
example = "Example: Parser('mistralocr:ocr-large')"
|
|
154
|
+
elif provider == "langchain":
|
|
155
|
+
example = "Example: Parser('langchain:gpt-3.5-turbo')"
|
|
156
|
+
|
|
157
|
+
raise TypeError(
|
|
158
|
+
f"{parser_cls.__name__} cannot be created because it is missing required argument(s): {', '.join(missing)}. {example}"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return parser_cls(**valid_args)
|
|
162
|
+
|
|
163
|
+
@abstractmethod
|
|
164
|
+
def parse(self, text: str, include_pagenumbers: bool = False):
|
|
165
|
+
pass
|
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from ..parser import BaseParser
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LangChainParser(BaseParser, name="langchain"):
|
|
7
|
+
def __init__(self, provider_and_model: str):
|
|
8
|
+
from langchain.llms import OpenAI
|
|
9
|
+
self.model = provider_and_model.split(":")[1]
|
|
10
|
+
self.model = OpenAI(model_name=self.model)
|
|
11
|
+
|
|
12
|
+
def parse(self, text: str) -> dict:
|
|
13
|
+
response = self.model(text)
|
|
14
|
+
return {"source": "LangChain", "output": response}
|
|
15
|
+
|
|
16
|
+
def __call__(self, file_path: Path) -> str:
|
|
17
|
+
return self.parse(file_path)
|
|
18
|
+
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import logging
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from ..parser import BaseParser
|
|
5
|
+
|
|
6
|
+
class LlamaParser(BaseParser, name="llamaparser"):
|
|
7
|
+
def __init__(self, result_type: str,
|
|
8
|
+
mode: bool,
|
|
9
|
+
provider_and_model: str,
|
|
10
|
+
merge_tables_across_pages_in_markdown: bool,
|
|
11
|
+
preserve_layout_alignment_across_pages: bool,
|
|
12
|
+
hide_footers: bool,
|
|
13
|
+
hide_headers: bool
|
|
14
|
+
) -> None:
|
|
15
|
+
logging.info("Initializing LlamaParser...")
|
|
16
|
+
self.result_type = result_type
|
|
17
|
+
self.mode = mode
|
|
18
|
+
self.provider_and_model = provider_and_model
|
|
19
|
+
self.merge_tables_across_pages_in_markdown = merge_tables_across_pages_in_markdown
|
|
20
|
+
self.preserve_layout_alignment_across_pages = preserve_layout_alignment_across_pages
|
|
21
|
+
self.hide_footers=hide_footers
|
|
22
|
+
self.hide_headers=hide_headers
|
|
23
|
+
|
|
24
|
+
from llama_parse import LlamaParse, ResultType
|
|
25
|
+
if result_type.lower() in ("md", "markdown"):
|
|
26
|
+
result_type = ResultType.MD
|
|
27
|
+
api_key = os.getenv("LLAMA-PARSER-API-TOKEN")
|
|
28
|
+
if not api_key:
|
|
29
|
+
raise EnvironmentError("Missing LLAMA-PARSER-API-TOKEN in .env file.")
|
|
30
|
+
self._parser = LlamaParse(api_key=api_key,
|
|
31
|
+
result_type=result_type,
|
|
32
|
+
premium_mode=self.mode,
|
|
33
|
+
merge_tables_across_pages_in_markdown=self.merge_tables_across_pages_in_markdown,
|
|
34
|
+
preserve_layout_alignment_across_pages=self.preserve_layout_alignment_across_pages,
|
|
35
|
+
hide_footers=self.hide_footers,
|
|
36
|
+
hide_headers=self.hide_headers)
|
|
37
|
+
|
|
38
|
+
def parse(self, file_path: Path, include_pagenumbers: bool=False) -> str:
|
|
39
|
+
"""
|
|
40
|
+
Parse the document at file_path.
|
|
41
|
+
Args:
|
|
42
|
+
file_path (Path): Path to the document to be parsed.
|
|
43
|
+
include_pagenumbers (bool): Whether to include page numbers in the output. If True, page numbers will be wrapped around each page's content, like so: <PAGE_NUMBER 1>...content...</PAGE_NUMBER 1>
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
if not include_pagenumbers:
|
|
47
|
+
documents = self._parser.load_data(str(file_path))
|
|
48
|
+
|
|
49
|
+
result = "\n".join(doc.text for doc in documents)
|
|
50
|
+
else:
|
|
51
|
+
documents = self._parser.parse(file_path)
|
|
52
|
+
|
|
53
|
+
if self.result_type.lower() in ("md", "markdown"):
|
|
54
|
+
result = "\n".join(f"<PAGE_NUMBER {idx}>{page.md}</PAGE_NUMBER {idx}>" for idx, page in enumerate(documents.pages, start=1))
|
|
55
|
+
else:
|
|
56
|
+
result = "\n".join(f"<PAGE_NUMBER {idx}>{page.text}</PAGE_NUMBER {idx}>" for idx, page in enumerate(documents.pages, start=1))
|
|
57
|
+
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
def __call__(self, file_path: Path) -> str:
|
|
61
|
+
return self.parse(file_path)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from ..parser import BaseParser
|
|
4
|
+
from PyPDF2 import PdfReader
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MistralOCRParser(BaseParser, name="mistralocr"):
|
|
8
|
+
def __init__(self, provider_and_model: str) -> None:
|
|
9
|
+
from mistralai import Mistral
|
|
10
|
+
self.model = provider_and_model.split(":")[1]
|
|
11
|
+
self.current_cost: float = 0.0
|
|
12
|
+
self.total_cost_euro: float = 0.0
|
|
13
|
+
api_key = os.getenv("MISTRAL-OCR-API-TOKEN")
|
|
14
|
+
if not api_key:
|
|
15
|
+
raise EnvironmentError("Missing MISTRAL-OCR-API-TOKEN in .env file.")
|
|
16
|
+
self.client = Mistral(api_key=api_key)
|
|
17
|
+
|
|
18
|
+
def parse(self, file_path: Path) -> str:
|
|
19
|
+
def upload_pdf(filename):
|
|
20
|
+
uploaded_pdf = self.client.files.upload(
|
|
21
|
+
file={"file_name": filename, "content": open(filename, "rb")},
|
|
22
|
+
purpose="ocr",
|
|
23
|
+
)
|
|
24
|
+
signed_url = self.client.files.get_signed_url(file_id=uploaded_pdf.id)
|
|
25
|
+
return signed_url.url
|
|
26
|
+
|
|
27
|
+
ocr_response = self.client.ocr.process(
|
|
28
|
+
model=self.model,
|
|
29
|
+
document={"type": "document_url", "document_url": upload_pdf(file_path)},
|
|
30
|
+
include_image_base64=True,
|
|
31
|
+
)
|
|
32
|
+
self.current_cost = 1 / 1000 * self._count_pages(file_path)
|
|
33
|
+
self.total_cost_euro += self.current_cost
|
|
34
|
+
return "\n".join(doc.markdown for doc in ocr_response.pages)
|
|
35
|
+
|
|
36
|
+
def __call__(self, file_path: Path) -> str:
|
|
37
|
+
return self.parse(file_path)
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def _count_pages(file_path: Path) -> int:
|
|
41
|
+
reader = PdfReader(str(file_path))
|
|
42
|
+
return len(reader.pages)
|
|
43
|
+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: HowdenParser
|
|
3
|
-
Version: 4.0
|
|
3
|
+
Version: 4.2.0
|
|
4
4
|
Summary: A simple configuration manager with Pydantic and JSON export.
|
|
5
5
|
License: MIT
|
|
6
6
|
Keywords: config,configuration,pydantic,json
|
|
@@ -11,21 +11,11 @@ Classifier: License :: OSI Approved :: MIT License
|
|
|
11
11
|
Classifier: Programming Language :: Python :: 3
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.12
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
-
Provides-Extra: dev
|
|
15
|
-
Requires-Dist: fitz (>=0.0.1.dev2,<0.0.2)
|
|
16
|
-
Requires-Dist: hf-xet (>=1.1.7,<2.0.0)
|
|
17
14
|
Requires-Dist: langchain (>=0.3.27,<0.4.0)
|
|
18
15
|
Requires-Dist: llama-parse (>=0.6.58,<0.7.0)
|
|
19
16
|
Requires-Dist: mistralai (>=1.9.3,<2.0.0)
|
|
20
17
|
Requires-Dist: pdf2image (>=1.17.0,<2.0.0)
|
|
21
18
|
Requires-Dist: pypdf2 (>=3.0.1,<4.0.0)
|
|
22
|
-
Requires-Dist: toml (>=0.10.2,<0.11.0) ; extra == "dev"
|
|
23
|
-
Requires-Dist: tomli-w (>=1.2.0,<2.0.0)
|
|
24
|
-
Requires-Dist: tomli-w (>=1.2.0,<2.0.0) ; extra == "dev"
|
|
25
|
-
Requires-Dist: torch (>=2.8.0,<3.0.0)
|
|
26
|
-
Requires-Dist: torchaudio (>=2.8.0,<3.0.0)
|
|
27
|
-
Requires-Dist: torchvision (>=0.23.0,<0.24.0)
|
|
28
|
-
Requires-Dist: transformers (==4.56.0)
|
|
29
19
|
Description-Content-Type: text/markdown
|
|
30
20
|
|
|
31
21
|
# OCR & LLM Parser
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "HowdenParser"
|
|
3
|
-
version = "4.0
|
|
3
|
+
version = "4.2.0"
|
|
4
4
|
description = "A simple configuration manager with Pydantic and JSON export."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12,<3.14"
|
|
@@ -20,28 +20,22 @@ dependencies = [
|
|
|
20
20
|
"mistralai (>=1.9.3,<2.0.0)",
|
|
21
21
|
"llama-parse (>=0.6.58,<0.7.0)",
|
|
22
22
|
"langchain (>=0.3.27,<0.4.0)",
|
|
23
|
-
"fitz (>=0.0.1.dev2,<0.0.2)",
|
|
24
23
|
"pypdf2 (>=3.0.1,<4.0.0)",
|
|
25
24
|
"pdf2image (>=1.17.0,<2.0.0)",
|
|
26
|
-
"torch (>=2.8.0,<3.0.0)",
|
|
27
|
-
"torchvision (>=0.23.0,<0.24.0)",
|
|
28
|
-
"torchaudio (>=2.8.0,<3.0.0)",
|
|
29
|
-
"hf-xet (>=1.1.7,<2.0.0)",
|
|
30
|
-
"transformers (==4.56.0)",
|
|
31
|
-
"tomli-w (>=1.2.0,<2.0.0)",
|
|
32
25
|
]
|
|
33
26
|
|
|
34
27
|
[project.license]
|
|
35
28
|
text = "MIT"
|
|
36
29
|
|
|
37
|
-
[project.optional-dependencies]
|
|
38
|
-
dev = [
|
|
39
|
-
"toml (>=0.10.2,<0.11.0)",
|
|
40
|
-
"tomli-w (>=1.2.0,<2.0.0)",
|
|
41
|
-
]
|
|
42
|
-
|
|
43
30
|
[build-system]
|
|
44
31
|
requires = [
|
|
45
32
|
"poetry-core>=2.0.0,<3.0.0",
|
|
46
33
|
]
|
|
47
34
|
build-backend = "poetry.core.masonry.api"
|
|
35
|
+
|
|
36
|
+
[dependency-groups]
|
|
37
|
+
dev = [
|
|
38
|
+
"toml (>=0.10.2,<0.11.0)",
|
|
39
|
+
"tomli-w (>=1.2.0,<2.0.0)",
|
|
40
|
+
"howdenconfig (>=1.0.6,<2.0.0)",
|
|
41
|
+
]
|
|
@@ -1,304 +0,0 @@
|
|
|
1
|
-
from abc import ABC, abstractmethod
|
|
2
|
-
import os
|
|
3
|
-
import logging
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
from PyPDF2 import PdfReader
|
|
6
|
-
import dotenv
|
|
7
|
-
from typing import overload, Literal
|
|
8
|
-
from inspect import signature, Signature
|
|
9
|
-
|
|
10
|
-
dotenv.load_dotenv()
|
|
11
|
-
logger = logging.getLogger(__name__)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
class BaseParser(ABC):
|
|
15
|
-
_registry: dict[str, type["BaseParser"]] = {}
|
|
16
|
-
|
|
17
|
-
def __init__(self):
|
|
18
|
-
# Every instance gets a .name attribute
|
|
19
|
-
self.name = getattr(self.__class__, "_parser_name", self.__class__.__name__)
|
|
20
|
-
|
|
21
|
-
def __init_subclass__(cls, name: str | None = None, **kwargs):
|
|
22
|
-
"""Automatically register subclasses under a key."""
|
|
23
|
-
super().__init_subclass__(**kwargs)
|
|
24
|
-
key = name or cls.__name__.lower().replace("parser", "")
|
|
25
|
-
BaseParser._registry[key] = cls
|
|
26
|
-
BaseParser._registry.pop("", None)
|
|
27
|
-
|
|
28
|
-
@abstractmethod
|
|
29
|
-
def parse(self, text: str):
|
|
30
|
-
pass
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
class Parser(BaseParser):
|
|
34
|
-
"""Factory + registry interface for all parsers."""
|
|
35
|
-
|
|
36
|
-
def __new__(cls, config_or_dict: "Parameter | dict | str" = None, **kwargs) -> BaseParser:
|
|
37
|
-
"""
|
|
38
|
-
Factory entrypoint.
|
|
39
|
-
Allows calling Parser(...) directly to create the correct subclass.
|
|
40
|
-
"""
|
|
41
|
-
return cls._create(config_or_dict, **kwargs)
|
|
42
|
-
|
|
43
|
-
@classmethod
|
|
44
|
-
def available_parsers(cls) -> None:
|
|
45
|
-
"""Print registered parsers and their init arguments."""
|
|
46
|
-
import inspect
|
|
47
|
-
result = {}
|
|
48
|
-
for name, parser_cls in BaseParser._registry.items():
|
|
49
|
-
sig = inspect.signature(parser_cls.__init__)
|
|
50
|
-
result[name] = [p for p in sig.parameters if p != "self"]
|
|
51
|
-
result.pop("", None)
|
|
52
|
-
for key, values in result.items():
|
|
53
|
-
print(f"{key} with parameters: {values}")
|
|
54
|
-
|
|
55
|
-
# ---- Overloads for IDE autocomplete ----
|
|
56
|
-
@overload
|
|
57
|
-
@classmethod
|
|
58
|
-
def _create(
|
|
59
|
-
cls,
|
|
60
|
-
*,
|
|
61
|
-
provider_and_model: Literal["llamaparser:"],
|
|
62
|
-
result_type: str,
|
|
63
|
-
mode: bool,
|
|
64
|
-
preserve_layout_alignment_across_pages: bool,
|
|
65
|
-
merge_tables_across_pages_in_markdown: bool,
|
|
66
|
-
hide_footers: bool,
|
|
67
|
-
hide_headers: bool,
|
|
68
|
-
) -> "LlamaParser": ...
|
|
69
|
-
|
|
70
|
-
@overload
|
|
71
|
-
@classmethod
|
|
72
|
-
def _create(
|
|
73
|
-
cls,
|
|
74
|
-
*,
|
|
75
|
-
provider_and_model: Literal["mistralocr:ocr-large", "mistralocr:ocr-small"],
|
|
76
|
-
) -> "MistralOCRParser": ...
|
|
77
|
-
|
|
78
|
-
@overload
|
|
79
|
-
@classmethod
|
|
80
|
-
def _create(
|
|
81
|
-
cls,
|
|
82
|
-
*,
|
|
83
|
-
provider_and_model: Literal["langchain:gpt-3.5-turbo", "langchain:gpt-4"],
|
|
84
|
-
) -> "LangChainParser": ...
|
|
85
|
-
|
|
86
|
-
@overload
|
|
87
|
-
@classmethod
|
|
88
|
-
def _create(
|
|
89
|
-
cls,
|
|
90
|
-
*,
|
|
91
|
-
provider_and_model: Literal[
|
|
92
|
-
"huggingface:microsoft/trocr-base-handwritten",
|
|
93
|
-
"huggingface:microsoft/trocr-large-printed",
|
|
94
|
-
],
|
|
95
|
-
) -> "HuggingFaceParser": ...
|
|
96
|
-
|
|
97
|
-
# ---- Implementation ----
|
|
98
|
-
@classmethod
|
|
99
|
-
def _create(cls, config_or_dict: "Parameter | dict | str" = None, **kwargs) -> BaseParser:
|
|
100
|
-
"""
|
|
101
|
-
Dynamically create parser instances.
|
|
102
|
-
Supports: Parameter, dict, str (provider_and_model), or kwargs.
|
|
103
|
-
"""
|
|
104
|
-
|
|
105
|
-
if config_or_dict is None:
|
|
106
|
-
config_dict = {}
|
|
107
|
-
elif hasattr(config_or_dict, "model_dump"):
|
|
108
|
-
config_dict = config_or_dict.model_dump()
|
|
109
|
-
elif isinstance(config_or_dict, dict):
|
|
110
|
-
config_dict = config_or_dict
|
|
111
|
-
elif isinstance(config_or_dict, str): # shorthand
|
|
112
|
-
config_dict = {"provider_and_model": config_or_dict}
|
|
113
|
-
else:
|
|
114
|
-
raise TypeError("Expected Parameter instance, dict, str, or None for config_or_dict")
|
|
115
|
-
|
|
116
|
-
merged_args = {**config_dict, **kwargs}
|
|
117
|
-
|
|
118
|
-
if "provider_and_model" not in merged_args:
|
|
119
|
-
raise ValueError("provider_and_model must be specified, e.g. 'llamaparser:'")
|
|
120
|
-
|
|
121
|
-
provider_and_model = str(merged_args.get("provider_and_model")).strip()
|
|
122
|
-
|
|
123
|
-
if ":" not in provider_and_model:
|
|
124
|
-
raise ValueError("provider_and_model must include a colon, e.g. 'llamaparser:'")
|
|
125
|
-
|
|
126
|
-
provider, model = provider_and_model.split(":", 1)
|
|
127
|
-
provider = (provider or "").strip().lower()
|
|
128
|
-
model = (model or "").strip().lower()
|
|
129
|
-
|
|
130
|
-
if not provider:
|
|
131
|
-
raise ValueError(f"Invalid provider_and_model '{provider_and_model}': provider part is empty.")
|
|
132
|
-
|
|
133
|
-
if provider not in BaseParser._registry:
|
|
134
|
-
raise ValueError(f"Unknown parser '{provider}'. Available: {list(BaseParser._registry)}")
|
|
135
|
-
|
|
136
|
-
parser_cls = BaseParser._registry[provider]
|
|
137
|
-
|
|
138
|
-
# --- Filter valid constructor args ---
|
|
139
|
-
sig = signature(parser_cls.__init__)
|
|
140
|
-
valid_args = {k: v for k, v in merged_args.items() if k in sig.parameters and k != "self"}
|
|
141
|
-
|
|
142
|
-
# Auto-fill common args
|
|
143
|
-
if "model" in sig.parameters and "model" not in valid_args:
|
|
144
|
-
valid_args["model"] = model
|
|
145
|
-
if "provider_and_model" in sig.parameters and "provider_and_model" not in valid_args:
|
|
146
|
-
valid_args["provider_and_model"] = provider_and_model
|
|
147
|
-
|
|
148
|
-
# --- Check required args ---
|
|
149
|
-
required_params = [
|
|
150
|
-
p.name
|
|
151
|
-
for p in sig.parameters.values()
|
|
152
|
-
if p.name != "self"
|
|
153
|
-
and p.default is Signature.empty
|
|
154
|
-
and p.kind in (p.POSITIONAL_OR_KEYWORD, p.KEYWORD_ONLY)
|
|
155
|
-
]
|
|
156
|
-
missing = [name for name in required_params if name not in valid_args]
|
|
157
|
-
|
|
158
|
-
# Special rule: for llamaparser we allow empty `model`
|
|
159
|
-
if provider != "llamaparser" and not model:
|
|
160
|
-
missing.append("model")
|
|
161
|
-
|
|
162
|
-
if missing:
|
|
163
|
-
example = ""
|
|
164
|
-
if provider == "llamaparser":
|
|
165
|
-
example = "Example: Parser('llamaparser:', result_type='md', mode=True, extract_tables=True)"
|
|
166
|
-
elif provider == "mistralocr":
|
|
167
|
-
example = "Example: Parser('mistralocr:ocr-large')"
|
|
168
|
-
elif provider == "huggingface":
|
|
169
|
-
example = "Example: Parser('huggingface:microsoft/trocr-base-handwritten')"
|
|
170
|
-
elif provider == "langchain":
|
|
171
|
-
example = "Example: Parser('langchain:gpt-3.5-turbo')"
|
|
172
|
-
|
|
173
|
-
raise TypeError(
|
|
174
|
-
f"{parser_cls.__name__} cannot be created because it is missing required argument(s): {', '.join(missing)}. {example}"
|
|
175
|
-
)
|
|
176
|
-
|
|
177
|
-
return parser_cls(**valid_args)
|
|
178
|
-
|
|
179
|
-
@abstractmethod
|
|
180
|
-
def parse(self, text: str):
|
|
181
|
-
pass
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
# --- Parsers ---
|
|
185
|
-
class MistralOCRParser(BaseParser, name="mistralocr"):
|
|
186
|
-
def __init__(self, provider_and_model: str) -> None:
|
|
187
|
-
from mistralai import Mistral
|
|
188
|
-
self.model = provider_and_model.split(":")[1]
|
|
189
|
-
self.current_cost: float = 0.0
|
|
190
|
-
self.total_cost_euro: float = 0.0
|
|
191
|
-
api_key = os.getenv("MISTRAL-OCR-API-TOKEN")
|
|
192
|
-
if not api_key:
|
|
193
|
-
raise EnvironmentError("Missing MISTRAL-OCR-API-TOKEN in .env file.")
|
|
194
|
-
self.client = Mistral(api_key=api_key)
|
|
195
|
-
|
|
196
|
-
def parse(self, file_path: Path) -> str:
|
|
197
|
-
def upload_pdf(filename):
|
|
198
|
-
uploaded_pdf = self.client.files.upload(
|
|
199
|
-
file={"file_name": filename, "content": open(filename, "rb")},
|
|
200
|
-
purpose="ocr",
|
|
201
|
-
)
|
|
202
|
-
signed_url = self.client.files.get_signed_url(file_id=uploaded_pdf.id)
|
|
203
|
-
return signed_url.url
|
|
204
|
-
|
|
205
|
-
ocr_response = self.client.ocr.process(
|
|
206
|
-
model=self.model,
|
|
207
|
-
document={"type": "document_url", "document_url": upload_pdf(file_path)},
|
|
208
|
-
include_image_base64=True,
|
|
209
|
-
)
|
|
210
|
-
self.current_cost = 1 / 1000 * self._count_pages(file_path)
|
|
211
|
-
self.total_cost_euro += self.current_cost
|
|
212
|
-
return "\n".join(doc.markdown for doc in ocr_response.pages)
|
|
213
|
-
|
|
214
|
-
def __call__(self, file_path: Path) -> str:
|
|
215
|
-
return self.parse(file_path)
|
|
216
|
-
|
|
217
|
-
@staticmethod
|
|
218
|
-
def _count_pages(file_path: Path) -> int:
|
|
219
|
-
reader = PdfReader(str(file_path))
|
|
220
|
-
return len(reader.pages)
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
class LangChainParser(BaseParser, name="langchain"):
|
|
224
|
-
def __init__(self, provider_and_model: str):
|
|
225
|
-
from langchain.llms import OpenAI
|
|
226
|
-
self.model = provider_and_model.split(":")[1]
|
|
227
|
-
self.model = OpenAI(model_name=self.model)
|
|
228
|
-
|
|
229
|
-
def parse(self, text: str) -> dict:
|
|
230
|
-
response = self.model(text)
|
|
231
|
-
return {"source": "LangChain", "output": response}
|
|
232
|
-
|
|
233
|
-
def __call__(self, file_path: Path) -> str:
|
|
234
|
-
return self.parse(file_path)
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
class LlamaParser(BaseParser, name="llamaparser"):
|
|
238
|
-
def __init__(self, result_type: str,
|
|
239
|
-
mode: bool,
|
|
240
|
-
provider_and_model: str,
|
|
241
|
-
merge_tables_across_pages_in_markdown: bool,
|
|
242
|
-
preserve_layout_alignment_across_pages: bool,
|
|
243
|
-
hide_footers: bool,
|
|
244
|
-
hide_headers: bool
|
|
245
|
-
) -> None:
|
|
246
|
-
logging.info("Initializing LlamaParser...")
|
|
247
|
-
self.result_type = result_type
|
|
248
|
-
self.mode = mode
|
|
249
|
-
self.provider_and_model = provider_and_model
|
|
250
|
-
self.merge_tables_across_pages_in_markdown = merge_tables_across_pages_in_markdown
|
|
251
|
-
self.preserve_layout_alignment_across_pages = preserve_layout_alignment_across_pages
|
|
252
|
-
self.hide_footers=hide_footers
|
|
253
|
-
self.hide_headers=hide_headers
|
|
254
|
-
|
|
255
|
-
from llama_parse import LlamaParse, ResultType
|
|
256
|
-
if result_type.lower() in ("md", "markdown"):
|
|
257
|
-
result_type = ResultType.MD
|
|
258
|
-
api_key = os.getenv("LLAMA-PARSER-API-TOKEN")
|
|
259
|
-
if not api_key:
|
|
260
|
-
raise EnvironmentError("Missing LLAMA-PARSER-API-TOKEN in .env file.")
|
|
261
|
-
self._parser = LlamaParse(api_key=api_key,
|
|
262
|
-
result_type=result_type,
|
|
263
|
-
premium_mode=self.mode,
|
|
264
|
-
merge_tables_across_pages_in_markdown=self.merge_tables_across_pages_in_markdown,
|
|
265
|
-
preserve_layout_alignment_across_pages=self.preserve_layout_alignment_across_pages,
|
|
266
|
-
hide_footers=self.hide_footers,
|
|
267
|
-
hide_headers=self.hide_headers)
|
|
268
|
-
|
|
269
|
-
def parse(self, file_path: Path) -> str:
|
|
270
|
-
documents = self._parser.load_data(str(file_path))
|
|
271
|
-
return "\n".join(doc.text for doc in documents)
|
|
272
|
-
|
|
273
|
-
def __call__(self, file_path: Path) -> str:
|
|
274
|
-
return self.parse(file_path)
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
class HuggingFaceParser(BaseParser, name="huggingface"):
|
|
278
|
-
def __init__(self, provider_and_model: str) -> None:
|
|
279
|
-
from transformers import TrOCRProcessor, VisionEncoderDecoderModel
|
|
280
|
-
api_key = os.getenv("HF-API-TOKEN")
|
|
281
|
-
if not api_key:
|
|
282
|
-
raise EnvironmentError("Missing HF-API-TOKEN in .env file.")
|
|
283
|
-
model_name = provider_and_model.split(":")[1]
|
|
284
|
-
logger.info(f"Loading Hugging Face OCR model: {model_name}")
|
|
285
|
-
self.processor = TrOCRProcessor.from_pretrained(model_name, token=api_key)
|
|
286
|
-
self.model = VisionEncoderDecoderModel.from_pretrained(model_name, token=api_key)
|
|
287
|
-
logger.info("Model and processor loaded successfully.")
|
|
288
|
-
|
|
289
|
-
def parse(self, file_path: Path) -> str:
|
|
290
|
-
from pdf2image import convert_from_path
|
|
291
|
-
logger.info(f"Converting PDF to images: {file_path}")
|
|
292
|
-
pages = convert_from_path(file_path, dpi=300)
|
|
293
|
-
logger.info(f"PDF conversion complete. Total pages: {len(pages)}")
|
|
294
|
-
|
|
295
|
-
all_text = ""
|
|
296
|
-
for i, page in enumerate(pages, start=1):
|
|
297
|
-
logger.info(f"Running OCR on page {i}/{len(pages)}")
|
|
298
|
-
pixel_values = self.processor(page, return_tensors="pt").pixel_values
|
|
299
|
-
generated_ids = self.model.generate(pixel_values)
|
|
300
|
-
text = self.processor.batch_decode(generated_ids, skip_special_tokens=True)[0]
|
|
301
|
-
all_text += text + "\n"
|
|
302
|
-
logger.debug(f"OCR text (page {i}): {text[:100]}...")
|
|
303
|
-
logger.info("OCR completed for all pages.")
|
|
304
|
-
return all_text
|
|
File without changes
|
|
File without changes
|
|
File without changes
|