docai-toolkit 0.1.0__py3-none-any.whl
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.
- docai_toolkit/__init__.py +3 -0
- docai_toolkit/config.py +79 -0
- docai_toolkit/hf_client.py +43 -0
- docai_toolkit/ocr/__init__.py +4 -0
- docai_toolkit/ocr/clients.py +83 -0
- docai_toolkit/ocr/pipeline.py +25 -0
- docai_toolkit/rag/__init__.py +4 -0
- docai_toolkit/rag/chat.py +55 -0
- docai_toolkit/rag/index.py +111 -0
- docai_toolkit-0.1.0.dist-info/METADATA +585 -0
- docai_toolkit-0.1.0.dist-info/RECORD +15 -0
- docai_toolkit-0.1.0.dist-info/WHEEL +5 -0
- docai_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- docai_toolkit-0.1.0.dist-info/licenses/LICENSE +437 -0
- docai_toolkit-0.1.0.dist-info/top_level.txt +1 -0
docai_toolkit/config.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from dataclasses import dataclass, field, asdict
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
CONFIG_PATH = Path.home() / ".docai" / "config.json"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class OcrConfig:
|
|
12
|
+
provider: str = "deepseek" # deepseek | tesseract
|
|
13
|
+
api_key: Optional[str] = None
|
|
14
|
+
model: Optional[str] = None # provider-specific
|
|
15
|
+
endpoint: Optional[str] = None # user-defined OCR API endpoint
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class EmbeddingConfig:
|
|
20
|
+
backend: str = "sentence-transformers" # sentence-transformers | huggingface-hub
|
|
21
|
+
model: str = "all-mpnet-base-v2"
|
|
22
|
+
device: str = "auto"
|
|
23
|
+
endpoint: Optional[str] = None # user-defined embedding API endpoint
|
|
24
|
+
api_key: Optional[str] = None # for hosted endpoints
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class LlmConfig:
|
|
29
|
+
backend: str = "huggingface-hub" # huggingface-hub | local-gguf | openai-compatible
|
|
30
|
+
model: str = "mistralai/Mistral-7B-Instruct-v0.1"
|
|
31
|
+
api_key: Optional[str] = None
|
|
32
|
+
max_new_tokens: int = 256
|
|
33
|
+
endpoint: Optional[str] = None # user-defined generation endpoint
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class AppConfig:
|
|
38
|
+
output_dir: Path = field(default_factory=lambda: Path("./outputs"))
|
|
39
|
+
ocr: OcrConfig = field(default_factory=OcrConfig)
|
|
40
|
+
embeddings: EmbeddingConfig = field(default_factory=EmbeddingConfig)
|
|
41
|
+
llm: LlmConfig = field(default_factory=LlmConfig)
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_env(cls) -> "AppConfig":
|
|
45
|
+
cfg = cls.load_from_file(CONFIG_PATH) or cls()
|
|
46
|
+
hf_token = (
|
|
47
|
+
os.getenv("HF_TOKEN")
|
|
48
|
+
or os.getenv("HUGGINGFACEHUB_API_TOKEN")
|
|
49
|
+
or os.getenv("DOC_AI_HF_TOKEN")
|
|
50
|
+
)
|
|
51
|
+
if hf_token:
|
|
52
|
+
cfg.llm.api_key = hf_token
|
|
53
|
+
cfg.embeddings.api_key = hf_token
|
|
54
|
+
|
|
55
|
+
output_dir_env = os.getenv("DOC_AI_OUTPUT_DIR")
|
|
56
|
+
if output_dir_env:
|
|
57
|
+
cfg.output_dir = Path(output_dir_env)
|
|
58
|
+
return cfg
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def load_from_file(cls, path: Path | None) -> "AppConfig | None":
|
|
62
|
+
if not path:
|
|
63
|
+
return None
|
|
64
|
+
if not path.exists():
|
|
65
|
+
return None
|
|
66
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
67
|
+
return cls(
|
|
68
|
+
output_dir=Path(data.get("output_dir", "./outputs")),
|
|
69
|
+
ocr=OcrConfig(**data.get("ocr", {})),
|
|
70
|
+
embeddings=EmbeddingConfig(**data.get("embeddings", {})),
|
|
71
|
+
llm=LlmConfig(**data.get("llm", {})),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def save(self, path: Path | None = None) -> None:
|
|
75
|
+
path = path or CONFIG_PATH
|
|
76
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
data = asdict(self)
|
|
78
|
+
data["output_dir"] = str(self.output_dir)
|
|
79
|
+
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Minimal Hugging Face / custom endpoint client."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import urllib.error
|
|
5
|
+
import urllib.request
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HuggingFaceClient:
|
|
10
|
+
def __init__(self, api_token: str | None, default_endpoint: Optional[str] = None, timeout: float = 30.0) -> None:
|
|
11
|
+
self.api_token = api_token
|
|
12
|
+
self.default_endpoint = default_endpoint
|
|
13
|
+
self.timeout = timeout
|
|
14
|
+
|
|
15
|
+
def post_json(
|
|
16
|
+
self,
|
|
17
|
+
payload: Dict[str, Any] | bytes,
|
|
18
|
+
endpoint: Optional[str] = None,
|
|
19
|
+
content_type: str = "application/json",
|
|
20
|
+
) -> Dict[str, Any] | Any:
|
|
21
|
+
url = endpoint or self.default_endpoint
|
|
22
|
+
if not url:
|
|
23
|
+
raise ValueError("Endpoint is required for HuggingFaceClient.")
|
|
24
|
+
|
|
25
|
+
if isinstance(payload, bytes):
|
|
26
|
+
data = payload
|
|
27
|
+
else:
|
|
28
|
+
data = json.dumps(payload).encode("utf-8")
|
|
29
|
+
|
|
30
|
+
req = urllib.request.Request(url, data=data)
|
|
31
|
+
req.add_header("Content-Type", content_type)
|
|
32
|
+
if self.api_token:
|
|
33
|
+
req.add_header("Authorization", f"Bearer {self.api_token}")
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp: # nosec B310 - user-configured endpoints
|
|
37
|
+
body = resp.read().decode("utf-8")
|
|
38
|
+
try:
|
|
39
|
+
return json.loads(body)
|
|
40
|
+
except json.JSONDecodeError:
|
|
41
|
+
return body
|
|
42
|
+
except urllib.error.URLError as exc:
|
|
43
|
+
raise RuntimeError(f"Request to {url} failed: {exc}") from exc
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
from docai_toolkit.hf_client import HuggingFaceClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class PageResult:
|
|
11
|
+
page_number: int
|
|
12
|
+
text: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OcrClient(ABC):
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def recognize(self, pdf_path: Path) -> List[PageResult]:
|
|
18
|
+
raise NotImplementedError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DeepSeekOcrClient(OcrClient):
|
|
22
|
+
"""Placeholder for DeepSeek OCR API integration."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, api_key: str, model: str | None = None) -> None:
|
|
25
|
+
self.api_key = api_key
|
|
26
|
+
self.model = model or "deepseek-ocr-default"
|
|
27
|
+
|
|
28
|
+
def recognize(self, pdf_path: Path) -> List[PageResult]:
|
|
29
|
+
raise NotImplementedError("DeepSeek OCR integration not yet implemented.")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RemoteOcrClient(OcrClient):
|
|
33
|
+
"""Generic OCR via a Hugging Face Inference or custom endpoint."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, api_key: str | None, endpoint: str, model: str | None = None) -> None:
|
|
36
|
+
self.client = HuggingFaceClient(api_key, default_endpoint=endpoint)
|
|
37
|
+
self.model = model
|
|
38
|
+
|
|
39
|
+
def recognize(self, pdf_path: Path) -> List[PageResult]:
|
|
40
|
+
with open(pdf_path, "rb") as handle:
|
|
41
|
+
pdf_bytes = handle.read()
|
|
42
|
+
|
|
43
|
+
payload = pdf_bytes
|
|
44
|
+
response = self.client.post_json(payload, content_type="application/pdf")
|
|
45
|
+
|
|
46
|
+
# Accept a few possible response shapes.
|
|
47
|
+
if isinstance(response, str):
|
|
48
|
+
pages = [response]
|
|
49
|
+
elif isinstance(response, list):
|
|
50
|
+
pages = [str(item) for item in response]
|
|
51
|
+
elif isinstance(response, dict):
|
|
52
|
+
if "pages" in response:
|
|
53
|
+
pages = [p.get("text", "") if isinstance(p, dict) else str(p) for p in response["pages"]]
|
|
54
|
+
elif "text" in response:
|
|
55
|
+
pages = [str(response["text"])]
|
|
56
|
+
else:
|
|
57
|
+
pages = [str(response)]
|
|
58
|
+
else:
|
|
59
|
+
pages = [""]
|
|
60
|
+
|
|
61
|
+
return [PageResult(page_number=i + 1, text=pages[i]) for i in range(len(pages))]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TesseractOcrClient(OcrClient):
|
|
65
|
+
"""Local fallback using pytesseract + pdf2image."""
|
|
66
|
+
|
|
67
|
+
def __init__(self) -> None:
|
|
68
|
+
try:
|
|
69
|
+
import pytesseract # type: ignore
|
|
70
|
+
from pdf2image import convert_from_path # type: ignore
|
|
71
|
+
except ImportError as exc: # pragma: no cover - optional path
|
|
72
|
+
raise RuntimeError("pytesseract and pdf2image are required for TesseractOcrClient.") from exc
|
|
73
|
+
|
|
74
|
+
self._pytesseract = pytesseract
|
|
75
|
+
self._convert_from_path = convert_from_path
|
|
76
|
+
|
|
77
|
+
def recognize(self, pdf_path: Path) -> List[PageResult]:
|
|
78
|
+
images = self._convert_from_path(str(pdf_path))
|
|
79
|
+
results: List[PageResult] = []
|
|
80
|
+
for idx, image in enumerate(images):
|
|
81
|
+
text = self._pytesseract.image_to_string(image)
|
|
82
|
+
results.append(PageResult(page_number=idx + 1, text=text))
|
|
83
|
+
return results
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Iterable
|
|
3
|
+
|
|
4
|
+
from .clients import OcrClient, PageResult
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def run_ocr_to_markdown(pdf_path: Path, output_dir: Path, client: OcrClient) -> Path:
|
|
8
|
+
"""Run OCR on a PDF and save combined Markdown output."""
|
|
9
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
10
|
+
md_path = output_dir / f"{pdf_path.stem}.md"
|
|
11
|
+
counter = 1
|
|
12
|
+
while md_path.exists():
|
|
13
|
+
md_path = output_dir / f"{pdf_path.stem}-{counter}.md"
|
|
14
|
+
counter += 1
|
|
15
|
+
|
|
16
|
+
pages: Iterable[PageResult] = client.recognize(pdf_path)
|
|
17
|
+
lines: list[str] = []
|
|
18
|
+
for page in pages:
|
|
19
|
+
lines.append(f"# Page {page.page_number}")
|
|
20
|
+
lines.append("")
|
|
21
|
+
lines.append(page.text.strip())
|
|
22
|
+
lines.append("\n")
|
|
23
|
+
|
|
24
|
+
md_path.write_text("\n".join(lines).strip() + "\n", encoding="utf-8")
|
|
25
|
+
return md_path
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from typing import List, Optional
|
|
2
|
+
|
|
3
|
+
from docai_toolkit.hf_client import HuggingFaceClient
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from langchain_community.llms import HuggingFacePipeline
|
|
7
|
+
from transformers import pipeline
|
|
8
|
+
except ImportError as _chat_import_error: # pragma: no cover - optional dependency
|
|
9
|
+
HuggingFacePipeline = None # type: ignore[assignment]
|
|
10
|
+
pipeline = None # type: ignore[assignment]
|
|
11
|
+
_CHAT_IMPORT_ERROR = _chat_import_error
|
|
12
|
+
else:
|
|
13
|
+
_CHAT_IMPORT_ERROR = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def chat_over_corpus(
|
|
17
|
+
db,
|
|
18
|
+
query: str,
|
|
19
|
+
model_id: str = "mistralai/Mistral-7B-Instruct-v0.1",
|
|
20
|
+
endpoint: Optional[str] = None,
|
|
21
|
+
api_key: Optional[str] = None,
|
|
22
|
+
max_new_tokens: int = 256,
|
|
23
|
+
) -> str:
|
|
24
|
+
"""Simple retrieve-then-generate over a FAISS db."""
|
|
25
|
+
docs = db.similarity_search(query, k=4)
|
|
26
|
+
context_blocks: List[str] = []
|
|
27
|
+
for doc in docs:
|
|
28
|
+
src = doc.metadata.get("source", "unknown")
|
|
29
|
+
context_blocks.append(f"Source: {src}\n{doc.page_content}")
|
|
30
|
+
context = "\n\n".join(context_blocks)
|
|
31
|
+
|
|
32
|
+
prompt = f"Answer the question using only the provided context.\n\nContext:\n{context}\n\nQuestion:\n{query}\n\nAnswer:"
|
|
33
|
+
|
|
34
|
+
if endpoint:
|
|
35
|
+
client = HuggingFaceClient(api_key, default_endpoint=endpoint)
|
|
36
|
+
resp = client.post_json(
|
|
37
|
+
{
|
|
38
|
+
"inputs": prompt,
|
|
39
|
+
"parameters": {"max_new_tokens": max_new_tokens},
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
if isinstance(resp, list) and resp and isinstance(resp[0], dict) and "generated_text" in resp[0]:
|
|
43
|
+
return resp[0]["generated_text"]
|
|
44
|
+
if isinstance(resp, dict) and "generated_text" in resp:
|
|
45
|
+
return resp["generated_text"]
|
|
46
|
+
if isinstance(resp, str):
|
|
47
|
+
return resp
|
|
48
|
+
raise ValueError("Unexpected response from generation endpoint.")
|
|
49
|
+
|
|
50
|
+
if pipeline is None or HuggingFacePipeline is None:
|
|
51
|
+
raise RuntimeError("transformers/langchain-community required for local HF generation.")
|
|
52
|
+
|
|
53
|
+
pipe = pipeline("text-generation", model=model_id, device_map="auto")
|
|
54
|
+
llm = HuggingFacePipeline(pipeline=pipe)
|
|
55
|
+
return llm(prompt)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Iterable, List, Optional
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
from langchain.text_splitter import RecursiveCharacterTextSplitter
|
|
6
|
+
from langchain_community.vectorstores import FAISS
|
|
7
|
+
from langchain.embeddings.base import Embeddings
|
|
8
|
+
from docai_toolkit.hf_client import HuggingFaceClient
|
|
9
|
+
except ImportError as _langchain_exc: # pragma: no cover - optional dependency
|
|
10
|
+
RecursiveCharacterTextSplitter = None # type: ignore[assignment]
|
|
11
|
+
FAISS = None # type: ignore[assignment]
|
|
12
|
+
Embeddings = object # type: ignore[assignment]
|
|
13
|
+
HuggingFaceClient = None # type: ignore[assignment]
|
|
14
|
+
_LANGCHAIN_IMPORT_ERROR = _langchain_exc
|
|
15
|
+
else:
|
|
16
|
+
_LANGCHAIN_IMPORT_ERROR = None
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from sentence_transformers import SentenceTransformer # type: ignore
|
|
20
|
+
except ImportError as exc: # pragma: no cover - optional dependency
|
|
21
|
+
SentenceTransformer = None # type: ignore[misc,assignment]
|
|
22
|
+
_IMPORT_ERROR = exc
|
|
23
|
+
else:
|
|
24
|
+
_IMPORT_ERROR = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _load_embedding_model(model_name: str):
|
|
28
|
+
if SentenceTransformer is None:
|
|
29
|
+
raise RuntimeError("sentence-transformers not installed") from _IMPORT_ERROR
|
|
30
|
+
return SentenceTransformer(model_name)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RemoteEmbeddings(Embeddings):
|
|
34
|
+
"""Call a remote embedding endpoint that accepts JSON {\"inputs\": text}."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, endpoint: str, api_key: Optional[str] = None):
|
|
37
|
+
self.client = HuggingFaceClient(api_key, default_endpoint=endpoint)
|
|
38
|
+
|
|
39
|
+
def embed_documents(self, texts: List[str]) -> List[List[float]]:
|
|
40
|
+
try:
|
|
41
|
+
resp = self.client.post_json({"inputs": texts})
|
|
42
|
+
return self._extract_batch(resp, len(texts))
|
|
43
|
+
except Exception:
|
|
44
|
+
vectors: List[List[float]] = []
|
|
45
|
+
for text in texts:
|
|
46
|
+
single = self.client.post_json({"inputs": text})
|
|
47
|
+
vectors.append(self._extract_vector(single))
|
|
48
|
+
return vectors
|
|
49
|
+
|
|
50
|
+
def embed_query(self, text: str) -> List[float]:
|
|
51
|
+
resp = self.client.post_json({"inputs": text})
|
|
52
|
+
return self._extract_vector(resp)
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def _extract_vector(response):
|
|
56
|
+
if isinstance(response, list) and response and isinstance(response[0], list):
|
|
57
|
+
return response[0]
|
|
58
|
+
if isinstance(response, list):
|
|
59
|
+
return response
|
|
60
|
+
raise ValueError("Unexpected embedding response format.")
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def _extract_batch(response, expected: int) -> List[List[float]]:
|
|
64
|
+
if isinstance(response, list) and response and isinstance(response[0], list):
|
|
65
|
+
return response
|
|
66
|
+
if isinstance(response, list) and len(response) == expected and isinstance(response[0], dict) and "embedding" in response[0]:
|
|
67
|
+
return [item["embedding"] for item in response]
|
|
68
|
+
raise ValueError("Unexpected batch embedding response format.")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def build_index_from_markdown(
|
|
72
|
+
markdown_files: Iterable[Path],
|
|
73
|
+
embedding_model: str = "all-mpnet-base-v2",
|
|
74
|
+
chunk_size: int = 1000,
|
|
75
|
+
chunk_overlap: int = 200,
|
|
76
|
+
persist_path: Optional[Path] = None,
|
|
77
|
+
embedding_endpoint: Optional[str] = None,
|
|
78
|
+
embedding_api_key: Optional[str] = None,
|
|
79
|
+
):
|
|
80
|
+
if RecursiveCharacterTextSplitter is None or FAISS is None:
|
|
81
|
+
raise RuntimeError("langchain is required for RAG. Install langchain and langchain-community.")
|
|
82
|
+
|
|
83
|
+
texts: List[str] = []
|
|
84
|
+
metadatas: List[dict] = []
|
|
85
|
+
|
|
86
|
+
for path in markdown_files:
|
|
87
|
+
content = path.read_text(encoding="utf-8")
|
|
88
|
+
texts.append(content)
|
|
89
|
+
metadatas.append({"source": str(path)})
|
|
90
|
+
|
|
91
|
+
splitter = RecursiveCharacterTextSplitter(chunk_size=chunk_size, chunk_overlap=chunk_overlap)
|
|
92
|
+
docs = splitter.create_documents(texts, metadatas=metadatas)
|
|
93
|
+
|
|
94
|
+
if embedding_endpoint:
|
|
95
|
+
embeddings = RemoteEmbeddings(embedding_endpoint, api_key=embedding_api_key)
|
|
96
|
+
else:
|
|
97
|
+
embeddings = _load_embedding_model(embedding_model)
|
|
98
|
+
db = FAISS.from_documents(docs, embeddings)
|
|
99
|
+
if persist_path:
|
|
100
|
+
persist_path.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
db.save_local(str(persist_path))
|
|
102
|
+
return db
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def load_index(persist_path: Path):
|
|
106
|
+
if not persist_path.exists():
|
|
107
|
+
raise FileNotFoundError(f"Persisted index not found at {persist_path}")
|
|
108
|
+
if FAISS is None:
|
|
109
|
+
raise RuntimeError("langchain-community is required to load indexes.")
|
|
110
|
+
embeddings = None # embeddings are restored from disk
|
|
111
|
+
return FAISS.load_local(str(persist_path), embeddings, allow_dangerous_deserialization=False)
|