docket-idp 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.
- docket/__init__.py +92 -0
- docket/amounts.py +68 -0
- docket/api.py +212 -0
- docket/checksums.py +358 -0
- docket/classify.py +290 -0
- docket/classify_tfidf.py +153 -0
- docket/cli.py +78 -0
- docket/config.py +101 -0
- docket/export/__init__.py +166 -0
- docket/export/einvoice.py +545 -0
- docket/export/erp.py +755 -0
- docket/extract.py +207 -0
- docket/forensics.py +385 -0
- docket/job_store.py +82 -0
- docket/language.py +98 -0
- docket/llm_client.py +287 -0
- docket/logging_setup.py +94 -0
- docket/matching.py +670 -0
- docket/ocr.py +342 -0
- docket/ocr_quality.py +110 -0
- docket/pdf.py +46 -0
- docket/pipeline.py +229 -0
- docket/py.typed +0 -0
- docket/review_queue.py +195 -0
- docket/schemas.py +610 -0
- docket/validate.py +1791 -0
- docket_idp-0.1.0.dist-info/METADATA +350 -0
- docket_idp-0.1.0.dist-info/RECORD +33 -0
- docket_idp-0.1.0.dist-info/WHEEL +5 -0
- docket_idp-0.1.0.dist-info/entry_points.txt +3 -0
- docket_idp-0.1.0.dist-info/licenses/LICENSE +202 -0
- docket_idp-0.1.0.dist-info/licenses/NOTICE +4 -0
- docket_idp-0.1.0.dist-info/top_level.txt +1 -0
docket/__init__.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
2
|
+
|
|
3
|
+
from .export import (
|
|
4
|
+
ExportError,
|
|
5
|
+
export_document,
|
|
6
|
+
get_exporter,
|
|
7
|
+
list_exporters,
|
|
8
|
+
register_exporter,
|
|
9
|
+
export_to_1c_client_bank,
|
|
10
|
+
export_to_1c_enterprise_xml,
|
|
11
|
+
export_to_facturae_xml,
|
|
12
|
+
export_to_quickbooks_iif,
|
|
13
|
+
export_to_quickbooks_json,
|
|
14
|
+
export_to_sap_idoc,
|
|
15
|
+
export_to_sap_journal_csv,
|
|
16
|
+
export_to_ubl_xml,
|
|
17
|
+
export_to_xero_csv,
|
|
18
|
+
export_to_xero_json,
|
|
19
|
+
export_to_zugferd_xml,
|
|
20
|
+
)
|
|
21
|
+
from .matching import (
|
|
22
|
+
match_invoice_to_po,
|
|
23
|
+
match_invoices_to_contract,
|
|
24
|
+
match_receipt_to_transactions,
|
|
25
|
+
match_three_way,
|
|
26
|
+
)
|
|
27
|
+
from .forensics import analyze_document_forensics
|
|
28
|
+
from .pipeline import process
|
|
29
|
+
from .schemas import (
|
|
30
|
+
AcceptanceAct,
|
|
31
|
+
BankStatement,
|
|
32
|
+
BankTransaction,
|
|
33
|
+
Contract,
|
|
34
|
+
Discrepancy,
|
|
35
|
+
DiscrepancyType,
|
|
36
|
+
DocType,
|
|
37
|
+
DocumentForensicReport,
|
|
38
|
+
HandwrittenAnnotation,
|
|
39
|
+
Invoice,
|
|
40
|
+
MatchingStatus,
|
|
41
|
+
MatchResult,
|
|
42
|
+
PipelineResult,
|
|
43
|
+
PurchaseOrder,
|
|
44
|
+
Receipt,
|
|
45
|
+
SignatureDetection,
|
|
46
|
+
StampDetection,
|
|
47
|
+
Waybill,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"__version__",
|
|
52
|
+
"process",
|
|
53
|
+
"export_document",
|
|
54
|
+
"ExportError",
|
|
55
|
+
"get_exporter",
|
|
56
|
+
"list_exporters",
|
|
57
|
+
"register_exporter",
|
|
58
|
+
"AcceptanceAct",
|
|
59
|
+
"BankStatement",
|
|
60
|
+
"BankTransaction",
|
|
61
|
+
"Contract",
|
|
62
|
+
"Discrepancy",
|
|
63
|
+
"DiscrepancyType",
|
|
64
|
+
"DocType",
|
|
65
|
+
"Invoice",
|
|
66
|
+
"MatchingStatus",
|
|
67
|
+
"MatchResult",
|
|
68
|
+
"PipelineResult",
|
|
69
|
+
"PurchaseOrder",
|
|
70
|
+
"Receipt",
|
|
71
|
+
"Waybill",
|
|
72
|
+
"match_invoice_to_po",
|
|
73
|
+
"match_invoices_to_contract",
|
|
74
|
+
"match_receipt_to_transactions",
|
|
75
|
+
"match_three_way",
|
|
76
|
+
"export_to_1c_client_bank",
|
|
77
|
+
"export_to_1c_enterprise_xml",
|
|
78
|
+
"export_to_facturae_xml",
|
|
79
|
+
"export_to_quickbooks_iif",
|
|
80
|
+
"export_to_quickbooks_json",
|
|
81
|
+
"export_to_sap_idoc",
|
|
82
|
+
"export_to_sap_journal_csv",
|
|
83
|
+
"export_to_ubl_xml",
|
|
84
|
+
"export_to_xero_csv",
|
|
85
|
+
"export_to_xero_json",
|
|
86
|
+
"export_to_zugferd_xml",
|
|
87
|
+
"analyze_document_forensics",
|
|
88
|
+
"DocumentForensicReport",
|
|
89
|
+
"StampDetection",
|
|
90
|
+
"SignatureDetection",
|
|
91
|
+
"HandwrittenAnnotation",
|
|
92
|
+
]
|
docket/amounts.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Money parsing that survives crossing a border.
|
|
2
|
+
|
|
3
|
+
`1.234,56` and `1,234.56` are the same amount written by a Spaniard and an
|
|
4
|
+
American. Parsing one convention and hoping is how a validation layer ends
|
|
5
|
+
up confidently comparing 25926.0 against 259.26 — which is exactly what
|
|
6
|
+
this pipeline did before this module existed.
|
|
7
|
+
|
|
8
|
+
Nothing here guesses a locale from the document. The convention is decided
|
|
9
|
+
per number, from the number's own shape, because a single document can
|
|
10
|
+
legitimately mix them (an English-language invoice issued in Barcelona) and
|
|
11
|
+
because a locale guess is one more thing that can be silently wrong.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
# A run of digits that may carry grouping and decimal marks. The lookarounds
|
|
18
|
+
# keep it from biting into longer alphanumeric tokens — an IBAN or an order
|
|
19
|
+
# number is not an amount.
|
|
20
|
+
MONEY_RE = re.compile(r"(?<![\w.,])(\d[\d.,]*\d|\d)(?![\w])")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_amount(raw: str) -> float | None:
|
|
24
|
+
"""Parse one money-looking token into a float, or None if it isn't one.
|
|
25
|
+
|
|
26
|
+
The rules, in order:
|
|
27
|
+
- both separators present -> whichever comes last is the decimal point
|
|
28
|
+
("1.234,56" -> 1234.56, "1,234.56" -> 1234.56)
|
|
29
|
+
- one separator, exactly three digits after it, and nothing else that
|
|
30
|
+
looks decimal -> grouping ("1.234" and "1,234" are both 1234)
|
|
31
|
+
- one separator otherwise -> decimal point ("259,26" -> 259.26)
|
|
32
|
+
"""
|
|
33
|
+
token = raw.strip().replace(" ", "").replace(" ", "")
|
|
34
|
+
if not token or not token[0].isdigit() or not token[-1].isdigit():
|
|
35
|
+
return None
|
|
36
|
+
if not re.fullmatch(r"[\d.,]+", token):
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
has_dot, has_comma = "." in token, "," in token
|
|
40
|
+
|
|
41
|
+
if has_dot and has_comma:
|
|
42
|
+
decimal_sep = "." if token.rindex(".") > token.rindex(",") else ","
|
|
43
|
+
grouping_sep = "," if decimal_sep == "." else "."
|
|
44
|
+
token = token.replace(grouping_sep, "").replace(decimal_sep, ".")
|
|
45
|
+
elif has_dot or has_comma:
|
|
46
|
+
sep = "." if has_dot else ","
|
|
47
|
+
if token.count(sep) > 1:
|
|
48
|
+
token = token.replace(sep, "") # 1.234.567 -> grouping only
|
|
49
|
+
else:
|
|
50
|
+
_, _, tail = token.partition(sep)
|
|
51
|
+
# Three trailing digits is grouping; two (or one, or four+) is a
|
|
52
|
+
# decimal fraction. Currencies don't group to three decimals.
|
|
53
|
+
token = token.replace(sep, "" if len(tail) == 3 else ".")
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
return float(token)
|
|
57
|
+
except ValueError:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def amounts_in(text: str) -> list[float]:
|
|
62
|
+
"""Every parseable money amount in a string, in order of appearance."""
|
|
63
|
+
values = []
|
|
64
|
+
for match in MONEY_RE.finditer(text):
|
|
65
|
+
value = parse_amount(match.group(1))
|
|
66
|
+
if value is not None:
|
|
67
|
+
values.append(value)
|
|
68
|
+
return values
|
docket/api.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""FastAPI service with bounded concurrency, durable jobs and review workflow.
|
|
2
|
+
|
|
3
|
+
Run it with `docket-api` (installed by `pip install "docket[api]"`) or
|
|
4
|
+
`uvicorn docket.api:app`.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from fastapi import Depends, FastAPI, File, Header, HTTPException, UploadFile
|
|
14
|
+
from fastapi.responses import FileResponse
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
from starlette.concurrency import run_in_threadpool
|
|
17
|
+
|
|
18
|
+
from . import __version__, config, job_store, review_queue
|
|
19
|
+
from .logging_setup import configure, get_logger
|
|
20
|
+
from .pdf import page_count
|
|
21
|
+
from .pipeline import process
|
|
22
|
+
from .schemas import PipelineResult
|
|
23
|
+
|
|
24
|
+
configure()
|
|
25
|
+
log = get_logger()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@asynccontextmanager
|
|
29
|
+
async def lifespan(_app: FastAPI):
|
|
30
|
+
for job in job_store.unfinished():
|
|
31
|
+
_schedule(job["job_id"])
|
|
32
|
+
yield
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
app = FastAPI(
|
|
36
|
+
title="docket",
|
|
37
|
+
description="Extract, classify and validate structured data from business documents.",
|
|
38
|
+
version=__version__,
|
|
39
|
+
lifespan=lifespan,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
_SUPPORTED_SUFFIXES = {".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".bmp", ".txt", ".md"}
|
|
43
|
+
_job_slots = asyncio.Semaphore(config.MAX_CONCURRENT_JOBS)
|
|
44
|
+
_active_tasks: set[asyncio.Task] = set()
|
|
45
|
+
_active_by_id: dict[str, asyncio.Task] = {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ReviewUpdate(BaseModel):
|
|
49
|
+
status: str
|
|
50
|
+
corrections: dict | None = None
|
|
51
|
+
actor: str = "reviewer"
|
|
52
|
+
note: str | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def require_api_key(
|
|
56
|
+
authorization: str | None = Header(default=None),
|
|
57
|
+
x_api_key: str | None = Header(default=None),
|
|
58
|
+
) -> None:
|
|
59
|
+
if config.API_KEY is None:
|
|
60
|
+
return
|
|
61
|
+
bearer = authorization.removeprefix("Bearer ") if authorization else None
|
|
62
|
+
if x_api_key != config.API_KEY and bearer != config.API_KEY:
|
|
63
|
+
raise HTTPException(401, "missing or invalid API key")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def _save_upload(file: UploadFile) -> Path:
|
|
67
|
+
suffix = Path(file.filename or "").suffix.lower()
|
|
68
|
+
if suffix not in _SUPPORTED_SUFFIXES:
|
|
69
|
+
raise HTTPException(400, f"unsupported file type: {suffix!r}")
|
|
70
|
+
config.JOB_UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
path = config.JOB_UPLOADS_DIR / f"{uuid4().hex}{suffix}"
|
|
72
|
+
size = 0
|
|
73
|
+
try:
|
|
74
|
+
with path.open("wb") as handle:
|
|
75
|
+
while chunk := await file.read(1024 * 1024):
|
|
76
|
+
size += len(chunk)
|
|
77
|
+
if size > config.MAX_FILE_BYTES:
|
|
78
|
+
raise HTTPException(413, f"file exceeds {config.MAX_FILE_BYTES} byte limit")
|
|
79
|
+
handle.write(chunk)
|
|
80
|
+
if suffix == ".pdf":
|
|
81
|
+
pages = page_count(path)
|
|
82
|
+
if pages > config.MAX_PDF_PAGES:
|
|
83
|
+
raise HTTPException(
|
|
84
|
+
413, f"PDF has {pages} pages; limit is {config.MAX_PDF_PAGES}"
|
|
85
|
+
)
|
|
86
|
+
return path
|
|
87
|
+
except Exception:
|
|
88
|
+
path.unlink(missing_ok=True)
|
|
89
|
+
raise
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def _run_job(job_id: str) -> dict:
|
|
93
|
+
job = job_store.get(job_id)
|
|
94
|
+
if job is None:
|
|
95
|
+
raise KeyError(job_id)
|
|
96
|
+
if job["status"] == "completed":
|
|
97
|
+
return job
|
|
98
|
+
async with _job_slots:
|
|
99
|
+
job_store.update(job_id, status="running", error=None)
|
|
100
|
+
try:
|
|
101
|
+
result = await run_in_threadpool(process, Path(job["path"]))
|
|
102
|
+
except Exception as exc: # noqa: BLE001
|
|
103
|
+
log.exception("pipeline failed", extra={"job_id": job_id})
|
|
104
|
+
return job_store.update(job_id, status="failed", error=f"{type(exc).__name__}: {exc}")
|
|
105
|
+
return job_store.update(
|
|
106
|
+
job_id, status="completed", result=result.model_dump(mode="json"), error=None
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _schedule(job_id: str) -> asyncio.Task:
|
|
111
|
+
existing = _active_by_id.get(job_id)
|
|
112
|
+
if existing is not None and not existing.done():
|
|
113
|
+
return existing
|
|
114
|
+
task = asyncio.create_task(_run_job(job_id))
|
|
115
|
+
_active_tasks.add(task)
|
|
116
|
+
_active_by_id[job_id] = task
|
|
117
|
+
|
|
118
|
+
def _finished(done: asyncio.Task) -> None:
|
|
119
|
+
_active_tasks.discard(done)
|
|
120
|
+
_active_by_id.pop(job_id, None)
|
|
121
|
+
|
|
122
|
+
task.add_done_callback(_finished)
|
|
123
|
+
return task
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@app.get("/health")
|
|
127
|
+
def health() -> dict:
|
|
128
|
+
return {"status": "ok"}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@app.post("/process", response_model=PipelineResult, dependencies=[Depends(require_api_key)])
|
|
132
|
+
async def process_document(
|
|
133
|
+
file: UploadFile = File(...),
|
|
134
|
+
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
|
|
135
|
+
) -> PipelineResult:
|
|
136
|
+
path = await _save_upload(file)
|
|
137
|
+
job = job_store.create(path, file.filename or path.name, idempotency_key=idempotency_key)
|
|
138
|
+
if Path(job["path"]) != path:
|
|
139
|
+
path.unlink(missing_ok=True)
|
|
140
|
+
job = await _schedule(job["job_id"])
|
|
141
|
+
if job["status"] != "completed":
|
|
142
|
+
raise HTTPException(500, f"pipeline failed: {job['error']}")
|
|
143
|
+
return PipelineResult.model_validate(job["result"])
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@app.post("/jobs", status_code=202, dependencies=[Depends(require_api_key)])
|
|
147
|
+
async def create_job(
|
|
148
|
+
file: UploadFile = File(...),
|
|
149
|
+
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
|
|
150
|
+
) -> dict:
|
|
151
|
+
path = await _save_upload(file)
|
|
152
|
+
job = job_store.create(path, file.filename or path.name, idempotency_key=idempotency_key)
|
|
153
|
+
if Path(job["path"]) != path:
|
|
154
|
+
path.unlink(missing_ok=True)
|
|
155
|
+
if job["status"] in {"queued", "running"}:
|
|
156
|
+
_schedule(job["job_id"])
|
|
157
|
+
return job
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@app.get("/jobs/{job_id}", dependencies=[Depends(require_api_key)])
|
|
161
|
+
def get_job(job_id: str) -> dict:
|
|
162
|
+
job = job_store.get(job_id)
|
|
163
|
+
if job is None:
|
|
164
|
+
raise HTTPException(404, "job not found")
|
|
165
|
+
return job
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@app.get("/review-queue", dependencies=[Depends(require_api_key)])
|
|
169
|
+
def get_review_queue() -> list[dict]:
|
|
170
|
+
return review_queue.list_pending()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@app.get("/review-queue/{document_id}", dependencies=[Depends(require_api_key)])
|
|
174
|
+
def get_review(document_id: str) -> dict:
|
|
175
|
+
record = review_queue.get(document_id)
|
|
176
|
+
if record is None:
|
|
177
|
+
raise HTTPException(404, "review record not found")
|
|
178
|
+
return record
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@app.get("/review-queue/{document_id}/original", dependencies=[Depends(require_api_key)])
|
|
182
|
+
def get_review_original(document_id: str) -> FileResponse:
|
|
183
|
+
record = review_queue.get(document_id)
|
|
184
|
+
if record is None or not record.get("original_path"):
|
|
185
|
+
raise HTTPException(404, "preserved original not found")
|
|
186
|
+
path = Path(record["original_path"])
|
|
187
|
+
if not path.is_file():
|
|
188
|
+
raise HTTPException(404, "preserved original not found")
|
|
189
|
+
return FileResponse(path, filename=path.name)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@app.patch("/review-queue/{document_id}", dependencies=[Depends(require_api_key)])
|
|
193
|
+
def update_review(document_id: str, update: ReviewUpdate) -> dict:
|
|
194
|
+
try:
|
|
195
|
+
return review_queue.update(document_id, **update.model_dump())
|
|
196
|
+
except KeyError as exc:
|
|
197
|
+
raise HTTPException(404, "review record not found") from exc
|
|
198
|
+
except ValueError as exc:
|
|
199
|
+
raise HTTPException(422, str(exc)) from exc
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def run() -> None:
|
|
203
|
+
"""Console entry point: `docket-api [--host H] [--port P]`."""
|
|
204
|
+
import argparse
|
|
205
|
+
|
|
206
|
+
import uvicorn
|
|
207
|
+
|
|
208
|
+
parser = argparse.ArgumentParser(description="Run the docket HTTP API.")
|
|
209
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
210
|
+
parser.add_argument("--port", type=int, default=8000)
|
|
211
|
+
args = parser.parse_args()
|
|
212
|
+
uvicorn.run(app, host=args.host, port=args.port)
|