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.
@@ -0,0 +1,3 @@
1
+ """DocAI Toolkit package (renamed to avoid PyPI conflicts)."""
2
+
3
+ __all__ = ["config"]
@@ -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,4 @@
1
+ from .clients import OcrClient, DeepSeekOcrClient, RemoteOcrClient, TesseractOcrClient
2
+ from .pipeline import run_ocr_to_markdown
3
+
4
+ __all__ = ["OcrClient", "DeepSeekOcrClient", "RemoteOcrClient", "TesseractOcrClient", "run_ocr_to_markdown"]
@@ -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,4 @@
1
+ from .index import build_index_from_markdown, load_index
2
+ from .chat import chat_over_corpus
3
+
4
+ __all__ = ["build_index_from_markdown", "load_index", "chat_over_corpus"]
@@ -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)