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 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)