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 +20 -0
- folioiq/azure_di.py +61 -0
- folioiq/client.py +183 -0
- folioiq/config.py +36 -0
- folioiq/eval.py +187 -0
- folioiq/exceptions.py +13 -0
- folioiq/io.py +49 -0
- folioiq/llm.py +80 -0
- folioiq/models.py +16 -0
- folioiq/pdf_text.py +48 -0
- folioiq/prompt.py +42 -0
- folioiq/router.py +68 -0
- folioiq/specs.py +181 -0
- folioiq/validation.py +80 -0
- folioiq-0.1.0.dist-info/METADATA +98 -0
- folioiq-0.1.0.dist-info/RECORD +19 -0
- folioiq-0.1.0.dist-info/WHEEL +4 -0
- folioiq-0.1.0.dist-info/entry_points.txt +2 -0
- folioiq-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
[](https://pypi.org/project/folioiq/)
|
|
38
|
+
[](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,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.
|