folioiq 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.
folioiq/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """FolioIQ — schema-driven document extraction."""
2
+
3
+ from folioiq.client import DocumentExtractor
4
+ from folioiq.exceptions import FolioIQError, NotImplementedPhaseError, SpecError
5
+ from folioiq.models import ExtractionResult
6
+ from folioiq.specs import ExtractionSpec, FieldSpec, build_model, load_spec
7
+
8
+ __all__ = [
9
+ "DocumentExtractor",
10
+ "ExtractionResult",
11
+ "ExtractionSpec",
12
+ "FieldSpec",
13
+ "FolioIQError",
14
+ "NotImplementedPhaseError",
15
+ "SpecError",
16
+ "build_model",
17
+ "load_spec",
18
+ ]
19
+
20
+ __version__ = "0.1.0"
folioiq/azure_di.py ADDED
@@ -0,0 +1,61 @@
1
+ """Azure Document Intelligence OCR (requires folioiq[azure])."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol
6
+
7
+ from folioiq.config import FolioIQSettings
8
+ from folioiq.exceptions import FolioIQError
9
+ from folioiq.io import DocumentInput, read_document_bytes
10
+
11
+
12
+ class OCRClient(Protocol):
13
+ def extract_text(self, document: DocumentInput) -> str:
14
+ ...
15
+
16
+
17
+ class AzureDIClient:
18
+ """OCR via Azure Document Intelligence prebuilt-read."""
19
+
20
+ def __init__(self, settings: FolioIQSettings, model_id: str = "prebuilt-read") -> None:
21
+ self.settings = settings
22
+ self.model_id = model_id
23
+ self._client = self._build_client()
24
+
25
+ def _build_client(self):
26
+ endpoint = self.settings.azure_doc_intelligence_endpoint
27
+ key = self.settings.azure_doc_intelligence_key
28
+ if not endpoint or not key:
29
+ raise FolioIQError(
30
+ "Azure Document Intelligence is not configured. "
31
+ "Set AZURE_DOC_INTELLIGENCE_ENDPOINT and AZURE_DOC_INTELLIGENCE_KEY."
32
+ )
33
+ try:
34
+ from azure.ai.documentintelligence import DocumentIntelligenceClient
35
+ from azure.core.credentials import AzureKeyCredential
36
+ except ImportError as exc:
37
+ raise FolioIQError(
38
+ "Azure DI requires: pip install 'folioiq[azure]'"
39
+ ) from exc
40
+
41
+ return DocumentIntelligenceClient(
42
+ endpoint=endpoint,
43
+ credential=AzureKeyCredential(key),
44
+ )
45
+
46
+ def extract_text(self, document: DocumentInput) -> str:
47
+ data, _ = read_document_bytes(document)
48
+ poller = self._client.begin_analyze_document(
49
+ self.model_id,
50
+ body=data,
51
+ content_type="application/octet-stream",
52
+ )
53
+ result = poller.result()
54
+ text = (result.content or "").strip()
55
+ if not text:
56
+ raise FolioIQError("Azure DI returned empty OCR text")
57
+ return text
58
+
59
+
60
+ def get_default_ocr(settings: FolioIQSettings) -> OCRClient:
61
+ return AzureDIClient(settings)
folioiq/client.py ADDED
@@ -0,0 +1,183 @@
1
+ """Main entrypoint — schema-driven extraction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import BinaryIO
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from folioiq.azure_di import OCRClient, get_default_ocr
11
+ from folioiq.config import FolioIQSettings
12
+ from folioiq.io import is_image_suffix, read_document_bytes, suffix_of
13
+ from folioiq.llm import LLMClient, get_default_llm
14
+ from folioiq.models import ExtractionResult
15
+ from folioiq.pdf_text import extract_pdf_text
16
+ from folioiq.prompt import build_extraction_prompt
17
+ from folioiq.router import ParserName, choose_parser
18
+ from folioiq.specs import ExtractionSpec, build_model, load_spec
19
+ from folioiq.validation import validate_extraction
20
+
21
+ DocumentInput = str | Path | bytes | BinaryIO
22
+
23
+ _SYSTEM = (
24
+ "You are a document extraction engine. "
25
+ "Extract fields exactly as present in the document text. "
26
+ "Never invent values. Use null for missing fields."
27
+ )
28
+
29
+
30
+ class DocumentExtractor:
31
+ """Extract structured fields from a document using a YAML spec."""
32
+
33
+ def __init__(
34
+ self,
35
+ settings: FolioIQSettings | None = None,
36
+ llm: LLMClient | None = None,
37
+ ocr: OCRClient | None = None,
38
+ ) -> None:
39
+ self.settings = settings or FolioIQSettings()
40
+ self._llm = llm
41
+ self._ocr = ocr
42
+
43
+ @classmethod
44
+ def from_env(cls) -> DocumentExtractor:
45
+ """Build from environment variables / .env."""
46
+ return cls(settings=FolioIQSettings())
47
+
48
+ def load_spec(
49
+ self,
50
+ spec: str | Path,
51
+ *,
52
+ instructions: str | Path | None = None,
53
+ ) -> ExtractionSpec:
54
+ """Load YAML spec (+ optional / sibling Markdown instructions)."""
55
+ return load_spec(spec, instructions=instructions)
56
+
57
+ def build_model(self, spec: ExtractionSpec) -> type[BaseModel]:
58
+ """Build a Pydantic model from the loaded spec."""
59
+ return build_model(spec)
60
+
61
+ def extract(
62
+ self,
63
+ document: DocumentInput,
64
+ spec: str | Path,
65
+ *,
66
+ instructions: str | Path | None = None,
67
+ ) -> ExtractionResult:
68
+ """Extract structured data from a PDF or image.
69
+
70
+ On validation failure (or parse failure), retries once with the
71
+ alternate parser when that makes sense.
72
+ """
73
+ loaded = self.load_spec(spec, instructions=instructions)
74
+ model_cls = self.build_model(loaded)
75
+ data, filename = read_document_bytes(document)
76
+ decision = choose_parser(data, filename, loaded)
77
+
78
+ primary = self._run(
79
+ data=data,
80
+ filename=filename,
81
+ spec=loaded,
82
+ model_cls=model_cls,
83
+ parser=decision.parser,
84
+ route_reason=decision.reason,
85
+ fallback_used=False,
86
+ )
87
+ if primary.is_valid:
88
+ return primary
89
+
90
+ alt = _alternate_parser(decision.parser, filename)
91
+ if alt is None:
92
+ return primary
93
+
94
+ secondary = self._run(
95
+ data=data,
96
+ filename=filename,
97
+ spec=loaded,
98
+ model_cls=model_cls,
99
+ parser=alt,
100
+ route_reason=f"fallback after {decision.parser} failed",
101
+ fallback_used=True,
102
+ primary_parser=decision.parser,
103
+ primary_errors=primary.validation_errors,
104
+ )
105
+ # Prefer a valid fallback; otherwise keep the richer primary errors
106
+ if secondary.is_valid:
107
+ return secondary
108
+ return primary
109
+
110
+ def _run(
111
+ self,
112
+ *,
113
+ data: bytes,
114
+ filename: str,
115
+ spec: ExtractionSpec,
116
+ model_cls: type[BaseModel],
117
+ parser: ParserName,
118
+ route_reason: str,
119
+ fallback_used: bool,
120
+ primary_parser: str | None = None,
121
+ primary_errors: list[str] | None = None,
122
+ ) -> ExtractionResult:
123
+ meta: dict = {
124
+ "spec": spec.name,
125
+ "spec_version": spec.version,
126
+ "parser": parser,
127
+ "route_reason": route_reason,
128
+ "fallback_used": fallback_used,
129
+ }
130
+ if primary_parser:
131
+ meta["primary_parser"] = primary_parser
132
+ if primary_errors:
133
+ meta["primary_errors"] = primary_errors
134
+
135
+ try:
136
+ text = self._read_text(data, parser)
137
+ except Exception as exc:
138
+ return ExtractionResult(
139
+ data={},
140
+ is_valid=False,
141
+ validation_errors=[str(exc)],
142
+ metadata=meta,
143
+ )
144
+
145
+ llm = self._llm or get_default_llm(self.settings)
146
+ meta["llm"] = type(llm).__name__
147
+ meta["chars"] = len(text)
148
+ prompt = build_extraction_prompt(spec)
149
+ user = f"{prompt}\n\n--- DOCUMENT TEXT ---\n{text}"
150
+
151
+ try:
152
+ parsed = llm.extract(system=_SYSTEM, user=user, model=model_cls)
153
+ payload = parsed.model_dump(mode="json")
154
+ except Exception as exc:
155
+ return ExtractionResult(
156
+ data={},
157
+ is_valid=False,
158
+ validation_errors=[str(exc)],
159
+ metadata=meta,
160
+ )
161
+
162
+ errors = validate_extraction(spec, payload)
163
+ return ExtractionResult(
164
+ data=payload,
165
+ is_valid=not errors,
166
+ validation_errors=errors,
167
+ metadata=meta,
168
+ )
169
+
170
+ def _read_text(self, data: bytes, parser: str) -> str:
171
+ if parser == "pymupdf":
172
+ return extract_pdf_text(data)
173
+ ocr = self._ocr or get_default_ocr(self.settings)
174
+ return ocr.extract_text(data)
175
+
176
+
177
+ def _alternate_parser(parser: ParserName, filename: str) -> ParserName | None:
178
+ """One flip: pymupdf ↔ azure_di. Images cannot use pymupdf."""
179
+ if parser == "pymupdf":
180
+ return "azure_di"
181
+ if is_image_suffix(suffix_of(filename)):
182
+ return None
183
+ return "pymupdf"
folioiq/config.py ADDED
@@ -0,0 +1,36 @@
1
+ """Environment / settings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import AliasChoices, Field
6
+ from pydantic_settings import BaseSettings, SettingsConfigDict
7
+
8
+
9
+ class FolioIQSettings(BaseSettings):
10
+ """Loads from env / .env."""
11
+
12
+ model_config = SettingsConfigDict(
13
+ env_file=".env",
14
+ env_file_encoding="utf-8",
15
+ extra="ignore",
16
+ populate_by_name=True,
17
+ )
18
+
19
+ pipeline_mode: str = "auto"
20
+
21
+ # Azure OpenAI — matches common AZURE_* names used by billing projects
22
+ azure_api_key: str | None = None
23
+ azure_api_base: str | None = None
24
+ azure_api_version: str | None = None
25
+ azure_deployment: str | None = Field(
26
+ default=None,
27
+ validation_alias=AliasChoices(
28
+ "FOLIOIQ_AZURE_MODEL",
29
+ "AZURE_OPENAI_DEPLOYMENT",
30
+ "model",
31
+ ),
32
+ )
33
+
34
+ # Azure Document Intelligence (Phase 3)
35
+ azure_doc_intelligence_endpoint: str | None = None
36
+ azure_doc_intelligence_key: str | None = None
folioiq/eval.py ADDED
@@ -0,0 +1,187 @@
1
+ """Simple fixture-pack evaluation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import re
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from folioiq.client import DocumentExtractor
13
+ from folioiq.exceptions import FolioIQError
14
+
15
+
16
+ @dataclass
17
+ class FieldScore:
18
+ name: str
19
+ expected: Any
20
+ actual: Any
21
+ match: bool
22
+
23
+
24
+ @dataclass
25
+ class PackReport:
26
+ pack: str
27
+ field_scores: list[FieldScore] = field(default_factory=list)
28
+ is_valid: bool = False
29
+ validation_errors: list[str] = field(default_factory=list)
30
+ metadata: dict[str, Any] = field(default_factory=dict)
31
+ skipped: bool = False
32
+ skip_reason: str = ""
33
+
34
+ @property
35
+ def scored(self) -> list[FieldScore]:
36
+ return [f for f in self.field_scores if f.expected is not None]
37
+
38
+ @property
39
+ def accuracy(self) -> float:
40
+ scored = self.scored
41
+ if not scored:
42
+ return 0.0
43
+ return sum(1 for f in scored if f.match) / len(scored)
44
+
45
+
46
+ def normalize_value(value: Any) -> str:
47
+ """Loose compare for eval (case/whitespace/punctuation tolerant)."""
48
+ if value is None:
49
+ return ""
50
+ if isinstance(value, float):
51
+ return f"{value:.4f}".rstrip("0").rstrip(".")
52
+ if isinstance(value, int):
53
+ return str(value)
54
+ text = str(value).strip().lower()
55
+ text = re.sub(r"\s+", " ", text)
56
+ text = re.sub(r"[,\s]+", " ", text)
57
+ return text.strip()
58
+
59
+
60
+ def values_match(expected: Any, actual: Any) -> bool:
61
+ if expected is None:
62
+ return True
63
+ if isinstance(expected, (int, float)) and isinstance(actual, (int, float)):
64
+ return abs(float(expected) - float(actual)) < 0.01
65
+ exp = normalize_value(expected)
66
+ act = normalize_value(actual)
67
+ if not exp:
68
+ return True
69
+ return exp == act or exp in act or act in exp
70
+
71
+
72
+ def score_fields(expected: dict[str, Any], actual: dict[str, Any]) -> list[FieldScore]:
73
+ scores: list[FieldScore] = []
74
+ for key, exp in expected.items():
75
+ if key.startswith("_"):
76
+ continue
77
+ act = actual.get(key)
78
+ scores.append(
79
+ FieldScore(
80
+ name=key,
81
+ expected=exp,
82
+ actual=act,
83
+ match=values_match(exp, act),
84
+ )
85
+ )
86
+ return scores
87
+
88
+
89
+ def find_document(pack_dir: Path) -> Path:
90
+ for name in ("document.pdf", "document.jpg", "document.jpeg", "document.png"):
91
+ path = pack_dir / name
92
+ if path.is_file():
93
+ return path
94
+ raise FolioIQError(f"No document.* found in {pack_dir}")
95
+
96
+
97
+ def run_pack(
98
+ pack_dir: str | Path,
99
+ extractor: DocumentExtractor | None = None,
100
+ ) -> PackReport:
101
+ """Run one fixture pack and score against expected.json."""
102
+ pack = Path(pack_dir).resolve()
103
+ report = PackReport(pack=pack.name)
104
+
105
+ expected_path = pack / "expected.json"
106
+ spec_path = pack / "spec.yaml"
107
+ if not expected_path.is_file() or not spec_path.is_file():
108
+ report.skipped = True
109
+ report.skip_reason = "missing spec.yaml or expected.json"
110
+ return report
111
+
112
+ expected = json.loads(expected_path.read_text(encoding="utf-8"))
113
+ # Pack with only null expectations is a placeholder
114
+ if all(v is None for k, v in expected.items() if not k.startswith("_")):
115
+ report.skipped = True
116
+ report.skip_reason = "expected.json has no labeled values yet"
117
+ return report
118
+
119
+ document = find_document(pack)
120
+ instructions = pack / "instructions.md"
121
+ extractor = extractor or DocumentExtractor.from_env()
122
+ result = extractor.extract(
123
+ document,
124
+ spec=spec_path,
125
+ instructions=instructions if instructions.is_file() else None,
126
+ )
127
+
128
+ report.is_valid = result.is_valid
129
+ report.validation_errors = list(result.validation_errors)
130
+ report.metadata = dict(result.metadata)
131
+ report.field_scores = score_fields(expected, result.data)
132
+ return report
133
+
134
+
135
+ def format_report(report: PackReport) -> str:
136
+ lines = [f"pack: {report.pack}"]
137
+ if report.skipped:
138
+ lines.append(f" skipped: {report.skip_reason}")
139
+ return "\n".join(lines)
140
+ lines.append(f" is_valid: {report.is_valid}")
141
+ lines.append(f" accuracy: {report.accuracy:.1%} ({sum(1 for f in report.scored if f.match)}/{len(report.scored)})")
142
+ lines.append(f" parser: {report.metadata.get('parser')}")
143
+ lines.append(f" fallback_used: {report.metadata.get('fallback_used')}")
144
+ if report.validation_errors:
145
+ lines.append(f" validation_errors: {report.validation_errors}")
146
+ for fs in report.field_scores:
147
+ if fs.expected is None:
148
+ continue
149
+ mark = "OK" if fs.match else "MISS"
150
+ lines.append(f" [{mark}] {fs.name}: expected={fs.expected!r} actual={fs.actual!r}")
151
+ return "\n".join(lines)
152
+
153
+
154
+ def main(argv: list[str] | None = None) -> int:
155
+ parser = argparse.ArgumentParser(description="Evaluate FolioIQ fixture packs")
156
+ parser.add_argument(
157
+ "pack",
158
+ nargs="?",
159
+ default=None,
160
+ help="Pack directory (default: all under tests/fixtures/packs)",
161
+ )
162
+ args = parser.parse_args(argv)
163
+
164
+ if args.pack:
165
+ packs = [Path(args.pack)]
166
+ else:
167
+ root = Path(__file__).resolve().parents[2] / "tests" / "fixtures" / "packs"
168
+ if not root.is_dir():
169
+ # installed package — look relative to cwd
170
+ root = Path("tests/fixtures/packs")
171
+ packs = sorted(p for p in root.iterdir() if p.is_dir())
172
+
173
+ extractor = DocumentExtractor.from_env()
174
+ exit_code = 0
175
+ for pack in packs:
176
+ report = run_pack(pack, extractor=extractor)
177
+ print(format_report(report))
178
+ print()
179
+ if report.skipped:
180
+ continue
181
+ if report.accuracy < 1.0 or not report.is_valid:
182
+ exit_code = 1
183
+ return exit_code
184
+
185
+
186
+ if __name__ == "__main__":
187
+ raise SystemExit(main())
folioiq/exceptions.py ADDED
@@ -0,0 +1,13 @@
1
+ """Shared exceptions."""
2
+
3
+
4
+ class FolioIQError(Exception):
5
+ """Base error for FolioIQ."""
6
+
7
+
8
+ class SpecError(FolioIQError):
9
+ """Invalid or missing extraction spec."""
10
+
11
+
12
+ class NotImplementedPhaseError(FolioIQError):
13
+ """Feature lands in a later phase — see PLAN.md."""
folioiq/io.py ADDED
@@ -0,0 +1,49 @@
1
+ """Shared document IO helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import BinaryIO
7
+
8
+ from folioiq.exceptions import FolioIQError
9
+
10
+ DocumentInput = str | Path | bytes | BinaryIO
11
+
12
+ _IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp", ".webp", ".gif"}
13
+ _PDF_EXTS = {".pdf"}
14
+
15
+
16
+ def read_document_bytes(document: DocumentInput) -> tuple[bytes, str]:
17
+ """Return (bytes, filename_hint) for routing / OCR."""
18
+ if isinstance(document, (bytes, bytearray)):
19
+ return bytes(document), "document.pdf"
20
+
21
+ if hasattr(document, "read"):
22
+ data = document.read()
23
+ if isinstance(data, str):
24
+ data = data.encode("utf-8")
25
+ name = getattr(document, "name", "document.bin")
26
+ return bytes(data), Path(str(name)).name
27
+
28
+ path = Path(document).expanduser().resolve()
29
+ if not path.is_file():
30
+ raise FolioIQError(f"Document not found: {path}")
31
+ return path.read_bytes(), path.name
32
+
33
+
34
+ def suffix_of(document: DocumentInput, filename_hint: str | None = None) -> str:
35
+ if filename_hint:
36
+ return Path(filename_hint).suffix.lower()
37
+ if isinstance(document, (str, Path)):
38
+ return Path(document).suffix.lower()
39
+ if hasattr(document, "name"):
40
+ return Path(str(document.name)).suffix.lower()
41
+ return ""
42
+
43
+
44
+ def is_image_suffix(suffix: str) -> bool:
45
+ return suffix in _IMAGE_EXTS
46
+
47
+
48
+ def is_pdf_suffix(suffix: str) -> bool:
49
+ return suffix in _PDF_EXTS or suffix == ""
folioiq/llm.py ADDED
@@ -0,0 +1,80 @@
1
+ """Azure OpenAI structured extraction (requires folioiq[azure])."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Protocol
6
+
7
+ from pydantic import BaseModel
8
+
9
+ from folioiq.config import FolioIQSettings
10
+ from folioiq.exceptions import FolioIQError
11
+
12
+
13
+ class LLMClient(Protocol):
14
+ def extract(self, *, system: str, user: str, model: type[BaseModel]) -> BaseModel:
15
+ ...
16
+
17
+
18
+ class AzureOpenAIClient:
19
+ """Thin wrapper around LangChain AzureChatOpenAI + structured output."""
20
+
21
+ def __init__(self, settings: FolioIQSettings) -> None:
22
+ self.settings = settings
23
+ self._llm = self._build_llm()
24
+
25
+ def _build_llm(self) -> Any:
26
+ missing = [
27
+ name
28
+ for name, value in [
29
+ ("AZURE_API_KEY", self.settings.azure_api_key),
30
+ ("AZURE_API_BASE", self.settings.azure_api_base),
31
+ ("AZURE_API_VERSION", self.settings.azure_api_version),
32
+ ("FOLIOIQ_AZURE_MODEL / model", self.settings.azure_deployment),
33
+ ]
34
+ if not value
35
+ ]
36
+ if missing:
37
+ raise FolioIQError(
38
+ "Azure OpenAI is not configured. Missing: "
39
+ + ", ".join(missing)
40
+ + ". Set env vars or install/configure folioiq[azure]."
41
+ )
42
+
43
+ try:
44
+ from langchain_openai import AzureChatOpenAI
45
+ except ImportError as exc:
46
+ raise FolioIQError(
47
+ "Azure OpenAI client requires: pip install 'folioiq[azure]'"
48
+ ) from exc
49
+
50
+ deployment = self.settings.azure_deployment or ""
51
+ deployment = deployment.replace("azure/", "")
52
+
53
+ return AzureChatOpenAI(
54
+ api_key=self.settings.azure_api_key,
55
+ api_version=self.settings.azure_api_version,
56
+ azure_endpoint=self.settings.azure_api_base,
57
+ azure_deployment=deployment,
58
+ model=deployment,
59
+ )
60
+
61
+ def extract(self, *, system: str, user: str, model: type[BaseModel]) -> BaseModel:
62
+ from langchain_core.messages import HumanMessage, SystemMessage
63
+
64
+ structured = self._llm.with_structured_output(model)
65
+ result = structured.invoke(
66
+ [
67
+ SystemMessage(content=system),
68
+ HumanMessage(content=user),
69
+ ]
70
+ )
71
+ if result is None:
72
+ raise FolioIQError("LLM returned empty structured output")
73
+ if isinstance(result, model):
74
+ return result
75
+ # Some versions return a dict
76
+ return model.model_validate(result)
77
+
78
+
79
+ def get_default_llm(settings: FolioIQSettings) -> LLMClient:
80
+ return AzureOpenAIClient(settings)
folioiq/models.py ADDED
@@ -0,0 +1,16 @@
1
+ """Public result types (minimal for Phase 0)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class ExtractionResult(BaseModel):
11
+ """Outcome of one extract() call."""
12
+
13
+ data: dict[str, Any] = Field(default_factory=dict)
14
+ is_valid: bool = False
15
+ validation_errors: list[str] = Field(default_factory=list)
16
+ metadata: dict[str, Any] = Field(default_factory=dict)
folioiq/pdf_text.py ADDED
@@ -0,0 +1,48 @@
1
+ """Pull plain text from digital PDFs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import BinaryIO
7
+
8
+ import fitz
9
+
10
+ from folioiq.exceptions import FolioIQError
11
+ from folioiq.io import DocumentInput, read_document_bytes, suffix_of
12
+
13
+ MIN_TEXT_CHARS = 20
14
+
15
+
16
+ def peek_pdf_text(document: DocumentInput) -> str:
17
+ """Return embedded PDF text (may be empty). Does not raise on short text."""
18
+ data, name = read_document_bytes(document)
19
+ suffix = suffix_of(document, name)
20
+ if suffix and suffix != ".pdf":
21
+ return ""
22
+
23
+ doc = fitz.open(stream=data, filetype="pdf")
24
+ try:
25
+ parts = [page.get_text("text") for page in doc]
26
+ return "\n".join(parts).strip()
27
+ finally:
28
+ doc.close()
29
+
30
+
31
+ def extract_pdf_text(document: DocumentInput) -> str:
32
+ """Extract embedded text from a digital PDF. Raises if too little text."""
33
+ # Prefer path open when we have a path (clearer errors)
34
+ if isinstance(document, (str, Path)):
35
+ path = Path(document).expanduser().resolve()
36
+ if not path.is_file():
37
+ raise FolioIQError(f"Document not found: {path}")
38
+ if path.suffix.lower() not in ("", ".pdf"):
39
+ raise FolioIQError(
40
+ f"pymupdf parser expects PDF (got {path.suffix or 'no extension'})."
41
+ )
42
+
43
+ text = peek_pdf_text(document)
44
+ if len(text) < MIN_TEXT_CHARS:
45
+ raise FolioIQError(
46
+ "PDF has little or no embedded text. Use parser azure_di (or pipeline auto)."
47
+ )
48
+ return text
folioiq/prompt.py ADDED
@@ -0,0 +1,42 @@
1
+ """Build extraction prompts from a loaded spec."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from folioiq.specs import ExtractionSpec, FieldSpec
6
+
7
+
8
+ def build_extraction_prompt(spec: ExtractionSpec) -> str:
9
+ """Human prompt: instructions + field list. Schema is enforced by the LLM client."""
10
+ lines: list[str] = []
11
+
12
+ if spec.instructions:
13
+ lines.append(spec.instructions.strip())
14
+ lines.append("")
15
+
16
+ lines.append(f"Extract fields for document type: {spec.name} (v{spec.version}).")
17
+ lines.append("Return only values found in the document. Use null when missing.")
18
+ lines.append("")
19
+ lines.append("Fields:")
20
+ for name, field in spec.extraction.fields.items():
21
+ lines.extend(_describe_field(name, field, indent=0))
22
+
23
+ return "\n".join(lines)
24
+
25
+
26
+ def _describe_field(name: str, field: FieldSpec, indent: int) -> list[str]:
27
+ pad = " " * indent
28
+ req = "required" if field.required else "optional"
29
+ lines = [f"{pad}- {name} ({field.type}, {req})"]
30
+ if field.description:
31
+ lines.append(f"{pad} {field.description.strip()}")
32
+ if field.type == "object" and field.fields:
33
+ for child_name, child in field.fields.items():
34
+ lines.extend(_describe_field(child_name, child, indent + 1))
35
+ if field.type == "array" and field.items is not None:
36
+ lines.append(f"{pad} items:")
37
+ if field.items.type == "object" and field.items.fields:
38
+ for child_name, child in field.items.fields.items():
39
+ lines.extend(_describe_field(child_name, child, indent + 2))
40
+ else:
41
+ lines.extend(_describe_field("item", field.items, indent + 2))
42
+ return lines
folioiq/router.py ADDED
@@ -0,0 +1,68 @@
1
+ """Choose pymupdf vs azure_di."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+ import fitz
9
+
10
+ from folioiq.io import is_image_suffix, suffix_of
11
+ from folioiq.pdf_text import MIN_TEXT_CHARS
12
+ from folioiq.specs import ExtractionSpec
13
+
14
+ ParserName = Literal["pymupdf", "azure_di"]
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class RouteDecision:
19
+ parser: ParserName
20
+ reason: str
21
+ text_chars: int = 0
22
+
23
+
24
+ def choose_parser(
25
+ data: bytes,
26
+ filename: str,
27
+ spec: ExtractionSpec,
28
+ ) -> RouteDecision:
29
+ """Pick a text parser for this document + spec."""
30
+ explicit = (
31
+ (spec.pipeline.parser or "").strip().lower()
32
+ if spec.pipeline.mode == "explicit"
33
+ else ""
34
+ )
35
+ if explicit in ("pymupdf", "azure_di"):
36
+ return RouteDecision(
37
+ parser=explicit, # type: ignore[arg-type]
38
+ reason=f"explicit pipeline.parser={explicit}",
39
+ )
40
+
41
+ suffix = suffix_of(filename)
42
+ if is_image_suffix(suffix):
43
+ return RouteDecision(parser="azure_di", reason="image file", text_chars=0)
44
+
45
+ text = _pdf_text_from_bytes(data)
46
+ if len(text) >= MIN_TEXT_CHARS:
47
+ return RouteDecision(
48
+ parser="pymupdf",
49
+ reason="pdf has embedded text",
50
+ text_chars=len(text),
51
+ )
52
+
53
+ return RouteDecision(
54
+ parser="azure_di",
55
+ reason="pdf has little/no text layer",
56
+ text_chars=len(text),
57
+ )
58
+
59
+
60
+ def _pdf_text_from_bytes(data: bytes) -> str:
61
+ try:
62
+ doc = fitz.open(stream=data, filetype="pdf")
63
+ except Exception:
64
+ return ""
65
+ try:
66
+ return "\n".join(page.get_text("text") for page in doc).strip()
67
+ finally:
68
+ doc.close()
folioiq/specs.py ADDED
@@ -0,0 +1,181 @@
1
+ """Load YAML extraction specs and build Pydantic models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date, datetime
6
+ from pathlib import Path
7
+ from typing import Any, Literal
8
+
9
+ import yaml
10
+ from pydantic import BaseModel, Field, create_model
11
+
12
+ from folioiq.exceptions import SpecError
13
+
14
+ FieldType = Literal[
15
+ "string",
16
+ "number",
17
+ "integer",
18
+ "boolean",
19
+ "date",
20
+ "datetime",
21
+ "array",
22
+ "object",
23
+ ]
24
+
25
+
26
+ class FieldSpec(BaseModel):
27
+ """One field in the extraction schema."""
28
+
29
+ type: FieldType = "string"
30
+ required: bool = False
31
+ description: str = ""
32
+ items: FieldSpec | None = None
33
+ fields: dict[str, FieldSpec] | None = None
34
+
35
+
36
+ class PipelineSpec(BaseModel):
37
+ mode: Literal["auto", "explicit"] = "auto"
38
+ parser: str | None = None
39
+ llm: str | None = None
40
+ model: str | None = None
41
+
42
+
43
+ class DocumentSpec(BaseModel):
44
+ allowed_types: list[str] = Field(default_factory=lambda: ["pdf", "png", "jpg", "jpeg"])
45
+
46
+
47
+ class ExtractionBlock(BaseModel):
48
+ fields: dict[str, FieldSpec]
49
+
50
+
51
+ class ExtractionSpec(BaseModel):
52
+ """Machine-readable extraction contract (from YAML)."""
53
+
54
+ name: str
55
+ version: str = "1.0"
56
+ document: DocumentSpec = Field(default_factory=DocumentSpec)
57
+ pipeline: PipelineSpec = Field(default_factory=PipelineSpec)
58
+ extraction: ExtractionBlock
59
+ # Filled by loader — not from YAML
60
+ path: Path | None = None
61
+ instructions: str | None = None
62
+
63
+
64
+ # Allow nested FieldSpec references
65
+ FieldSpec.model_rebuild()
66
+
67
+
68
+ _TYPE_MAP: dict[str, type] = {
69
+ "string": str,
70
+ "number": float,
71
+ "integer": int,
72
+ "boolean": bool,
73
+ "date": date,
74
+ "datetime": datetime,
75
+ }
76
+
77
+
78
+ def load_spec(
79
+ path: str | Path,
80
+ *,
81
+ instructions: str | Path | None = None,
82
+ ) -> ExtractionSpec:
83
+ """Load a YAML spec and optional Markdown instructions.
84
+
85
+ If ``instructions`` is omitted, uses a sibling ``.md`` with the same stem
86
+ when it exists (e.g. ``invoice.yaml`` → ``invoice.md``).
87
+ """
88
+ spec_path = Path(path).expanduser().resolve()
89
+ if not spec_path.is_file():
90
+ raise SpecError(f"Spec not found: {spec_path}")
91
+
92
+ try:
93
+ raw = yaml.safe_load(spec_path.read_text(encoding="utf-8"))
94
+ except yaml.YAMLError as exc:
95
+ raise SpecError(f"Invalid YAML in {spec_path}: {exc}") from exc
96
+
97
+ if not isinstance(raw, dict):
98
+ raise SpecError(f"Spec must be a mapping: {spec_path}")
99
+
100
+ try:
101
+ spec = ExtractionSpec.model_validate(raw)
102
+ except Exception as exc:
103
+ raise SpecError(f"Invalid spec structure in {spec_path}: {exc}") from exc
104
+
105
+ if not spec.extraction.fields:
106
+ raise SpecError(f"Spec has no extraction.fields: {spec_path}")
107
+
108
+ spec.path = spec_path
109
+ spec.instructions = _resolve_instructions(spec_path, instructions)
110
+ return spec
111
+
112
+
113
+ def _resolve_instructions(
114
+ spec_path: Path,
115
+ instructions: str | Path | None,
116
+ ) -> str | None:
117
+ if instructions is None:
118
+ sibling = spec_path.with_suffix(".md")
119
+ if sibling.is_file():
120
+ return sibling.read_text(encoding="utf-8")
121
+ return None
122
+
123
+ instr_path = Path(instructions)
124
+ if instr_path.is_file():
125
+ return instr_path.read_text(encoding="utf-8")
126
+ # Treat as inline markdown text
127
+ return str(instructions)
128
+
129
+
130
+ def build_model(spec: ExtractionSpec) -> type[BaseModel]:
131
+ """Build a dynamic Pydantic model from ``extraction.fields``."""
132
+ return _build_object_model(
133
+ name=_safe_model_name(spec.name),
134
+ fields=spec.extraction.fields,
135
+ )
136
+
137
+
138
+ def _safe_model_name(name: str) -> str:
139
+ cleaned = "".join(ch if ch.isalnum() else "_" for ch in name.strip())
140
+ return cleaned[:50] or "Document"
141
+
142
+
143
+ def _build_object_model(name: str, fields: dict[str, FieldSpec]) -> type[BaseModel]:
144
+ definitions: dict[str, Any] = {}
145
+ for field_name, field in fields.items():
146
+ py_type, default = _python_type(field, field_name)
147
+ definitions[field_name] = (
148
+ py_type,
149
+ Field(default=default, description=field.description or None),
150
+ )
151
+ return create_model(name, **definitions) # type: ignore[call-overload]
152
+
153
+
154
+ def _python_type(field: FieldSpec, name: str) -> tuple[Any, Any]:
155
+ """Return (annotation, default) for one field."""
156
+ required = field.required
157
+ default: Any = ... if required else None
158
+
159
+ if field.type == "object":
160
+ if not field.fields:
161
+ raise SpecError(f"Field '{name}' type=object needs nested fields")
162
+ nested = _build_object_model(f"{_safe_model_name(name)}Item", field.fields)
163
+ annotation: Any = nested if required else nested | None
164
+ return annotation, default
165
+
166
+ if field.type == "array":
167
+ if field.items is None:
168
+ raise SpecError(f"Field '{name}' type=array needs items")
169
+ # List elements are concrete types (not Optional).
170
+ item_spec = field.items.model_copy(update={"required": True})
171
+ item_type, _ = _python_type(item_spec, f"{name}_item")
172
+ list_type: Any = list[item_type] # type: ignore[valid-type]
173
+ annotation = list_type if required else list_type | None
174
+ return annotation, default
175
+
176
+ base = _TYPE_MAP.get(field.type)
177
+ if base is None:
178
+ raise SpecError(f"Unsupported field type '{field.type}' on '{name}'")
179
+
180
+ annotation = base if required else base | None
181
+ return annotation, default
folioiq/validation.py ADDED
@@ -0,0 +1,80 @@
1
+ """Post-extraction checks against the YAML spec."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from folioiq.specs import ExtractionSpec, FieldSpec
8
+
9
+
10
+ def validate_extraction(spec: ExtractionSpec, data: dict[str, Any]) -> list[str]:
11
+ """Return human-readable errors. Empty list means OK."""
12
+ errors: list[str] = []
13
+ for name, field in spec.extraction.fields.items():
14
+ errors.extend(_check_field(name, field, data.get(name)))
15
+ return errors
16
+
17
+
18
+ def _check_field(name: str, field: FieldSpec, value: Any) -> list[str]:
19
+ errors: list[str] = []
20
+
21
+ if field.required and _is_empty(value):
22
+ errors.append(f"missing required field: {name}")
23
+ return errors
24
+
25
+ if _is_empty(value):
26
+ return errors
27
+
28
+ if field.type == "array":
29
+ if not isinstance(value, list):
30
+ errors.append(f"field '{name}' should be an array")
31
+ return errors
32
+ if field.items is not None:
33
+ for i, item in enumerate(value):
34
+ if field.items.type == "object" and field.items.fields:
35
+ if not isinstance(item, dict):
36
+ errors.append(f"field '{name}[{i}]' should be an object")
37
+ continue
38
+ for child_name, child in field.items.fields.items():
39
+ errors.extend(
40
+ _check_field(f"{name}[{i}].{child_name}", child, item.get(child_name))
41
+ )
42
+ return errors
43
+
44
+ if field.type == "object":
45
+ if not isinstance(value, dict):
46
+ errors.append(f"field '{name}' should be an object")
47
+ return errors
48
+ if field.fields:
49
+ for child_name, child in field.fields.items():
50
+ errors.extend(
51
+ _check_field(f"{name}.{child_name}", child, value.get(child_name))
52
+ )
53
+ return errors
54
+
55
+ expected = _simple_type(field.type)
56
+ if expected is not None and not isinstance(value, expected):
57
+ # JSON numbers may arrive as int for number fields — accept both
58
+ if field.type == "number" and isinstance(value, (int, float)):
59
+ return errors
60
+ errors.append(
61
+ f"field '{name}' expected {field.type}, got {type(value).__name__}"
62
+ )
63
+ return errors
64
+
65
+
66
+ def _is_empty(value: Any) -> bool:
67
+ if value is None:
68
+ return True
69
+ if isinstance(value, str) and not value.strip():
70
+ return True
71
+ return False
72
+
73
+
74
+ def _simple_type(field_type: str) -> type | tuple[type, ...] | None:
75
+ return {
76
+ "string": str,
77
+ "integer": int,
78
+ "boolean": bool,
79
+ "number": (int, float),
80
+ }.get(field_type)
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.5
2
+ Name: folioiq
3
+ Version: 0.1.0
4
+ Summary: Schema-driven document extraction with pluggable providers
5
+ Project-URL: Homepage, https://github.com/auspicious123/folioiq-sdk
6
+ Project-URL: Repository, https://github.com/auspicious123/folioiq-sdk
7
+ Project-URL: Issues, https://github.com/auspicious123/folioiq-sdk/issues
8
+ Author-email: auspicious123 <auspicious123@users.noreply.github.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: azure,document-extraction,llm,ocr,pdf,pydantic
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Requires-Python: >=3.12
20
+ Requires-Dist: pydantic-settings>=2.0
21
+ Requires-Dist: pydantic>=2.0
22
+ Requires-Dist: pymupdf>=1.24.0
23
+ Requires-Dist: python-dotenv>=1.0
24
+ Requires-Dist: pyyaml>=6.0
25
+ Provides-Extra: azure
26
+ Requires-Dist: azure-ai-documentintelligence>=1.0.0; extra == 'azure'
27
+ Requires-Dist: langchain-core>=0.3.0; extra == 'azure'
28
+ Requires-Dist: langchain-openai>=0.2.0; extra == 'azure'
29
+ Provides-Extra: dev
30
+ Requires-Dist: build>=1.0; extra == 'dev'
31
+ Requires-Dist: pytest>=8.0; extra == 'dev'
32
+ Requires-Dist: twine>=5.0; extra == 'dev'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # FolioIQ
36
+
37
+ [![PyPI](https://img.shields.io/badge/pypi-folioiq-blue)](https://pypi.org/project/folioiq/)
38
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
39
+
40
+ Schema-driven document extraction. YAML spec (+ optional MD) in → structured data out.
41
+
42
+ ```python
43
+ from folioiq import DocumentExtractor
44
+
45
+ extractor = DocumentExtractor.from_env()
46
+ result = extractor.extract("invoice.pdf", spec="examples/invoice.yaml")
47
+
48
+ if result.is_valid:
49
+ print(result.data)
50
+ else:
51
+ print(result.validation_errors)
52
+ ```
53
+
54
+ ## Install
55
+
56
+ ```bash
57
+ pip install folioiq
58
+ pip install "folioiq[azure]" # Azure OpenAI + Document Intelligence
59
+ ```
60
+
61
+ Or from source:
62
+
63
+ ```bash
64
+ git clone https://github.com/auspicious123/folioiq-sdk.git
65
+ cd folioiq-sdk
66
+ python -m venv .venv && source .venv/bin/activate
67
+ pip install -e ".[azure,dev]"
68
+ cp .env.example .env # fill Azure vars
69
+ ```
70
+
71
+ ## Spec only (no LLM)
72
+
73
+ ```python
74
+ spec = extractor.load_spec("examples/invoice.yaml")
75
+ Model = extractor.build_model(spec)
76
+ ```
77
+
78
+ ## Pipeline override (YAML)
79
+
80
+ ```yaml
81
+ pipeline:
82
+ mode: explicit
83
+ parser: azure_di # or pymupdf
84
+ ```
85
+
86
+ ## Eval
87
+
88
+ ```bash
89
+ pip install -e ".[azure,dev]"
90
+ folioiq-eval # all fixture packs
91
+ folioiq-eval tests/fixtures/packs/po
92
+ ```
93
+
94
+ ## License
95
+
96
+ MIT — see [LICENSE](LICENSE).
97
+
98
+ See [PLAN.md](PLAN.md) for design phases.
@@ -0,0 +1,19 @@
1
+ folioiq/__init__.py,sha256=5URI84rXr4oIYvEbtH8ZIhzte9lFxO85PgDQBL-Ootk,531
2
+ folioiq/azure_di.py,sha256=smdpgCTrtp_GtAYGt_atFM_FYeLH_XveeVCNWX8KWwE,2058
3
+ folioiq/client.py,sha256=u9sCjJBloZ7INvR-evY48O2sHtCLjLzMXzKqgvfLPn0,5818
4
+ folioiq/config.py,sha256=yAm_4SpzC7z9T3n8dpv-QhWren8KRRYFjLeEJ_EaUkY,996
5
+ folioiq/eval.py,sha256=PDvoNPRHSIi5JTKZeSeoS2pGDADP_FB_U8q9HEUAntM,5875
6
+ folioiq/exceptions.py,sha256=osKS17RU9Ljaguu2ghr89R1CmtkE4d3l8f2t1vgKAJ4,277
7
+ folioiq/io.py,sha256=LslTFhR5JYYhkrdnqjgPo0QXJ9Uh-rz9JT7rV7cMgdI,1480
8
+ folioiq/llm.py,sha256=zDomhoHhDdNUcagC3FRF6J63ig9cfG0EwKsKAbtr8ew,2653
9
+ folioiq/models.py,sha256=x9BdT_SIw-7ZZT3FqrDssa7LrRY5z5rrAOaPTPyCJqE,431
10
+ folioiq/pdf_text.py,sha256=ZpRSQyfHe_eVdcPnXHQ80m-v5vTOCKnBUZKzEVDdK8g,1522
11
+ folioiq/prompt.py,sha256=7pJ8bv95SSq-scu3S_oATbVdhH50Evnk4I-4ok4SjAg,1662
12
+ folioiq/router.py,sha256=hxIz8rCH5uyzmelVPL2NDUIevsWWAEefqt3s13einnE,1701
13
+ folioiq/specs.py,sha256=PJgc7eWj4qLg1PWs5SUvWDbkIWjAcfXO1ggT9CAZyHo,5350
14
+ folioiq/validation.py,sha256=HtkoJ3Wwqrt1afZ1_AFAIl6nQNFThq6L12IFs_rDJmU,2680
15
+ folioiq-0.1.0.dist-info/METADATA,sha256=j-bz1NsyH9pL44-Fz-smIZco8ca8Qn6qiW4F78cKoSI,2758
16
+ folioiq-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
17
+ folioiq-0.1.0.dist-info/entry_points.txt,sha256=Ar2I0Y11v71Z0bTQwtn7969hJ6UwzUKDisypp4rtsZg,51
18
+ folioiq-0.1.0.dist-info/licenses/LICENSE,sha256=aLJXfZyz4RfMsiBPap0jm6YZtCn0nT85T2C_9tXDPW4,1077
19
+ folioiq-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ folioiq-eval = folioiq.eval:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 FolioIQ contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.