readanydoc 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.
Files changed (170) hide show
  1. readanydoc/__init__.py +12 -0
  2. readanydoc/api/__init__.py +6 -0
  3. readanydoc/api/__main__.py +26 -0
  4. readanydoc/api/app.py +300 -0
  5. readanydoc/api/config.py +72 -0
  6. readanydoc/api/contracts.py +139 -0
  7. readanydoc/api/errors.py +86 -0
  8. readanydoc/api/inputs.py +142 -0
  9. readanydoc/api/schemas.py +372 -0
  10. readanydoc/api/service.py +426 -0
  11. readanydoc/api/webhooks.py +90 -0
  12. readanydoc/api/worker.py +79 -0
  13. readanydoc/cli.py +140 -0
  14. readanydoc/cvsafe.py +60 -0
  15. readanydoc/intake/__init__.py +12 -0
  16. readanydoc/intake/common.py +94 -0
  17. readanydoc/intake/docx_reader.py +356 -0
  18. readanydoc/intake/errors.py +71 -0
  19. readanydoc/intake/heic.py +66 -0
  20. readanydoc/intake/legacy.py +144 -0
  21. readanydoc/intake/msg.py +123 -0
  22. readanydoc/intake/pdf.py +100 -0
  23. readanydoc/intake/pptx_reader.py +114 -0
  24. readanydoc/intake/prepare.py +510 -0
  25. readanydoc/intake/sheets.py +425 -0
  26. readanydoc/intake/sniff.py +96 -0
  27. readanydoc/intake/text.py +103 -0
  28. readanydoc/layout/__init__.py +51 -0
  29. readanydoc/layout/base.py +51 -0
  30. readanydoc/layout/cached.py +61 -0
  31. readanydoc/layout/claude.py +101 -0
  32. readanydoc/layout/onnx.py +285 -0
  33. readanydoc/library.py +296 -0
  34. readanydoc/localmodels.py +83 -0
  35. readanydoc/model/__init__.py +42 -0
  36. readanydoc/model/document.py +280 -0
  37. readanydoc/modelspec.py +79 -0
  38. readanydoc/paths.py +53 -0
  39. readanydoc/pipeline/__init__.py +23 -0
  40. readanydoc/pipeline/assemble.py +70 -0
  41. readanydoc/pipeline/assembly/__init__.py +1 -0
  42. readanydoc/pipeline/assembly/annotations.py +69 -0
  43. readanydoc/pipeline/assembly/furniture.py +255 -0
  44. readanydoc/pipeline/assembly/headings.py +261 -0
  45. readanydoc/pipeline/assembly/paragraphs.py +88 -0
  46. readanydoc/pipeline/assembly/tables.py +320 -0
  47. readanydoc/pipeline/base.py +178 -0
  48. readanydoc/pipeline/checks/__init__.py +97 -0
  49. readanydoc/pipeline/checks/common.py +66 -0
  50. readanydoc/pipeline/checks/complete.py +426 -0
  51. readanydoc/pipeline/checks/flags.py +62 -0
  52. readanydoc/pipeline/checks/gridrepair.py +128 -0
  53. readanydoc/pipeline/checks/htmlgrid.py +288 -0
  54. readanydoc/pipeline/checks/numbers.py +318 -0
  55. readanydoc/pipeline/checks/reread.py +92 -0
  56. readanydoc/pipeline/common.py +38 -0
  57. readanydoc/pipeline/figures/__init__.py +530 -0
  58. readanydoc/pipeline/figures/layout_pictures.py +153 -0
  59. readanydoc/pipeline/figures/locate.py +212 -0
  60. readanydoc/pipeline/forms/__init__.py +99 -0
  61. readanydoc/pipeline/forms/acroform.py +80 -0
  62. readanydoc/pipeline/forms/checkbox.py +136 -0
  63. readanydoc/pipeline/forms/fillable.py +303 -0
  64. readanydoc/pipeline/forms/grid.py +293 -0
  65. readanydoc/pipeline/forms/lines.py +100 -0
  66. readanydoc/pipeline/forms/locate.py +135 -0
  67. readanydoc/pipeline/forms/region.py +159 -0
  68. readanydoc/pipeline/forms/rules.py +105 -0
  69. readanydoc/pipeline/ground.py +720 -0
  70. readanydoc/pipeline/grounding/__init__.py +1 -0
  71. readanydoc/pipeline/grounding/align.py +270 -0
  72. readanydoc/pipeline/grounding/confidence.py +66 -0
  73. readanydoc/pipeline/grounding/ocr.py +204 -0
  74. readanydoc/pipeline/grounding/sources.py +274 -0
  75. readanydoc/pipeline/grounding/tables.py +70 -0
  76. readanydoc/pipeline/grounding/tokens.py +165 -0
  77. readanydoc/pipeline/intake.py +286 -0
  78. readanydoc/pipeline/layout.py +63 -0
  79. readanydoc/pipeline/mdblocks.py +128 -0
  80. readanydoc/pipeline/read.py +925 -0
  81. readanydoc/pipeline/reading/__init__.py +7 -0
  82. readanydoc/pipeline/reading/budget.py +150 -0
  83. readanydoc/pipeline/reading/effort_route.py +49 -0
  84. readanydoc/pipeline/reading/escalate.py +71 -0
  85. readanydoc/pipeline/reading/evidence.py +101 -0
  86. readanydoc/pipeline/reading/form_prefetch.py +84 -0
  87. readanydoc/pipeline/reading/formulas.py +137 -0
  88. readanydoc/pipeline/reading/fusion.py +128 -0
  89. readanydoc/pipeline/reading/handwriting.py +133 -0
  90. readanydoc/pipeline/reading/robust.py +142 -0
  91. readanydoc/pipeline/reading/select.py +55 -0
  92. readanydoc/pipeline/reading/strips.py +129 -0
  93. readanydoc/pipeline/reading/tables.py +409 -0
  94. readanydoc/pipeline/reading/zoom.py +158 -0
  95. readanydoc/pipeline/reading/zoom_gate.py +91 -0
  96. readanydoc/pipeline/reading/zoom_regions.py +72 -0
  97. readanydoc/pipeline/reading/zoom_schedule.py +67 -0
  98. readanydoc/pipeline/regions/__init__.py +207 -0
  99. readanydoc/pipeline/regions/assemble.py +169 -0
  100. readanydoc/pipeline/regions/complete.py +103 -0
  101. readanydoc/pipeline/regions/crops.py +209 -0
  102. readanydoc/pipeline/regions/fields.py +580 -0
  103. readanydoc/pipeline/regions/formevidence.py +187 -0
  104. readanydoc/pipeline/regions/forms.py +114 -0
  105. readanydoc/pipeline/regions/furniture.py +614 -0
  106. readanydoc/pipeline/regions/match.py +162 -0
  107. readanydoc/pipeline/regions/structure.py +264 -0
  108. readanydoc/pipeline/regions/tables.py +164 -0
  109. readanydoc/pipeline/request.py +72 -0
  110. readanydoc/pipeline/shape.py +260 -0
  111. readanydoc/pipeline/shaping/__init__.py +0 -0
  112. readanydoc/pipeline/shaping/agentic.py +215 -0
  113. readanydoc/pipeline/shaping/chunking.py +339 -0
  114. readanydoc/pipeline/shaping/embed.py +82 -0
  115. readanydoc/pipeline/shaping/merge.py +89 -0
  116. readanydoc/pipeline/shaping/options.py +129 -0
  117. readanydoc/pipeline/shaping/page_range.py +49 -0
  118. readanydoc/pipeline/shaping/response.py +112 -0
  119. readanydoc/pipeline/shaping/searchable.py +248 -0
  120. readanydoc/pipeline/shaping/summarize.py +74 -0
  121. readanydoc/pipeline/shaping/tables.py +414 -0
  122. readanydoc/pipeline/tables/__init__.py +145 -0
  123. readanydoc/pipeline/tables/evidence.py +163 -0
  124. readanydoc/pipeline/tables/hygiene.py +151 -0
  125. readanydoc/pipeline/tables/prefetch.py +116 -0
  126. readanydoc/pipeline/tables/reread.py +462 -0
  127. readanydoc/pipeline/triage.py +188 -0
  128. readanydoc/pipeline/typing/__init__.py +13 -0
  129. readanydoc/pipeline/typing/layout.py +217 -0
  130. readanydoc/pipeline/typing/markup.py +190 -0
  131. readanydoc/pipeline/verify.py +287 -0
  132. readanydoc/profiles.py +115 -0
  133. readanydoc/readers/__init__.py +177 -0
  134. readanydoc/readers/anthropic_api.py +165 -0
  135. readanydoc/readers/anthropic_batch.py +191 -0
  136. readanydoc/readers/base.py +116 -0
  137. readanydoc/readers/batch_mode.py +28 -0
  138. readanydoc/readers/cached.py +109 -0
  139. readanydoc/readers/claude_cli.py +73 -0
  140. readanydoc/readers/crop_cache.py +75 -0
  141. readanydoc/readers/live_cache.py +88 -0
  142. readanydoc/readers/ocr_words.py +68 -0
  143. readanydoc/readers/openai_compatible.py +94 -0
  144. readanydoc/readers/prompts.py +112 -0
  145. readanydoc/readers/sizing.py +198 -0
  146. readanydoc/readers/usage.py +111 -0
  147. readanydoc/serialize/__init__.py +3 -0
  148. readanydoc/serialize/citations.py +134 -0
  149. readanydoc/serialize/reducto.py +120 -0
  150. readanydoc/store/__init__.py +8 -0
  151. readanydoc/store/cleanup.py +279 -0
  152. readanydoc/store/db.py +61 -0
  153. readanydoc/store/files.py +153 -0
  154. readanydoc/store/jobs.py +223 -0
  155. readanydoc/tasks/__init__.py +51 -0
  156. readanydoc/tasks/classify.py +137 -0
  157. readanydoc/tasks/common.py +223 -0
  158. readanydoc/tasks/detect.py +274 -0
  159. readanydoc/tasks/edit.py +1089 -0
  160. readanydoc/tasks/extract.py +1049 -0
  161. readanydoc/tasks/extract_extras.py +325 -0
  162. readanydoc/tasks/llm.py +477 -0
  163. readanydoc/tasks/schema.py +473 -0
  164. readanydoc/tasks/split.py +358 -0
  165. readanydoc/textnorm.py +78 -0
  166. readanydoc-0.1.0.dist-info/METADATA +206 -0
  167. readanydoc-0.1.0.dist-info/RECORD +170 -0
  168. readanydoc-0.1.0.dist-info/WHEEL +4 -0
  169. readanydoc-0.1.0.dist-info/entry_points.txt +2 -0
  170. readanydoc-0.1.0.dist-info/licenses/LICENSE +202 -0
readanydoc/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """readanydoc: an open-source document parser around vision models, with Reducto's HTTP API.
2
+
3
+ Use it as a library (``readanydoc.parse``, ``readanydoc.extract``) or run the server (``readanydoc serve``).
4
+ """
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ from .library import (Block, BoundingBox, Citation, ExtractResult, ParseResult, Usage, # noqa: E402
9
+ extract, parse)
10
+
11
+ __all__ = ["parse", "extract", "ParseResult", "ExtractResult", "Block", "BoundingBox", "Citation", "Usage",
12
+ "__version__"]
@@ -0,0 +1,6 @@
1
+ """HTTP API: a drop-in for Reducto's v3 routes. ``create_app()`` builds the FastAPI app."""
2
+
3
+ from .app import create_app
4
+ from .config import ServerSettings
5
+
6
+ __all__ = ["ServerSettings", "create_app"]
@@ -0,0 +1,26 @@
1
+ """``python -m readanydoc.api [--host H] [--port P]`` (same as ``readanydoc serve``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+
9
+ def serve(host: str = "127.0.0.1", port: int = 8000, log_level: str = "info") -> int:
10
+ import uvicorn
11
+
12
+ from .app import create_app
13
+ uvicorn.run(create_app(), host=host, port=port, log_level=log_level)
14
+ return 0
15
+
16
+
17
+ def main(argv: list[str] | None = None) -> int:
18
+ ap = argparse.ArgumentParser(prog="readanydoc.api")
19
+ ap.add_argument("--host", default="127.0.0.1")
20
+ ap.add_argument("--port", type=int, default=8000)
21
+ args = ap.parse_args(argv)
22
+ return serve(args.host, args.port)
23
+
24
+
25
+ if __name__ == "__main__":
26
+ sys.exit(main())
readanydoc/api/app.py ADDED
@@ -0,0 +1,300 @@
1
+ """FastAPI app: Reducto's v3 routes on top of the readanydoc pipeline.
2
+
3
+ uv run readanydoc serve --port 8000 (or: python -m readanydoc.api)
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import hmac
9
+ import json
10
+ import os
11
+ from contextlib import asynccontextmanager
12
+ from datetime import datetime, timezone
13
+ from typing import Any, Optional
14
+
15
+ from fastapi import Depends, FastAPI, Query, Request
16
+ from fastapi.concurrency import run_in_threadpool
17
+ from fastapi.exceptions import RequestValidationError
18
+ from fastapi.responses import FileResponse, JSONResponse
19
+ from starlette.exceptions import HTTPException as StarletteHTTPException
20
+
21
+ from .. import __version__
22
+ from ..store import ACTIVE, Database, FileStore, Job, JobStore
23
+ from . import errors, schemas, webhooks
24
+ from .config import API_KEY_ENV, ServerSettings
25
+ from .inputs import ApiGone
26
+ from .service import PIPELINE_GONE, Service
27
+ from .worker import Worker
28
+
29
+ # GET /job only knows these four in Reducto's schema; the rest are folded unless READANYDOC_JOB_STATUS_DETAIL=1
30
+ PUBLIC_STATUS = {"Pending": "Pending", "InProgress": "Pending", "Completing": "Pending",
31
+ "Completed": "Completed", "Failed": "Failed", "Cancelled": "Failed"}
32
+
33
+
34
+ def require_auth(request: Request) -> None:
35
+ key = os.environ.get(API_KEY_ENV)
36
+ if not key:
37
+ return # open server (local use)
38
+ scheme, _, token = request.headers.get("authorization", "").partition(" ")
39
+ if scheme.lower() != "bearer" or not hmac.compare_digest(token.strip().encode(), key.encode()):
40
+ raise errors.unauthorized()
41
+
42
+
43
+ def _iso(ts: Optional[float]) -> Optional[str]:
44
+ return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() if ts else None
45
+
46
+
47
+ def create_app(settings: Optional[ServerSettings] = None) -> FastAPI:
48
+ settings = settings or ServerSettings()
49
+ settings.data_dir.mkdir(parents=True, exist_ok=True)
50
+ db = Database(settings.data_dir / "readanydoc.sqlite3")
51
+ files = FileStore(db, settings.data_dir / "files")
52
+ jobs = JobStore(db, settings.data_dir / "jobs")
53
+ service = Service(settings, files, jobs)
54
+ worker = Worker(settings, db, files, jobs, service)
55
+ jobs.recover()
56
+
57
+ @asynccontextmanager
58
+ async def lifespan(_: FastAPI):
59
+ worker.ensure_started()
60
+ yield
61
+ worker.stop()
62
+
63
+ app = FastAPI(title="readanydoc (Reducto-compatible API)", version=__version__, lifespan=lifespan)
64
+ app.state.settings, app.state.db, app.state.files = settings, db, files
65
+ app.state.jobs, app.state.service, app.state.worker = jobs, service, worker
66
+ auth = [Depends(require_auth)]
67
+
68
+ # ---- errors ----
69
+ @app.exception_handler(errors.ApiError)
70
+ async def _api_error(_: Request, exc: errors.ApiError):
71
+ return JSONResponse(exc.body(), status_code=exc.status, headers=exc.headers)
72
+
73
+ @app.exception_handler(StarletteHTTPException)
74
+ async def _http_error(_: Request, exc: StarletteHTTPException):
75
+ name = {401: "AUTH_ERROR", 404: "NOT_FOUND", 429: "RATE_LIMIT"}.get(exc.status_code, "INVALID_CONFIG")
76
+ err = errors.ApiError(exc.status_code, name, str(exc.detail), headers=getattr(exc, "headers", None))
77
+ return JSONResponse(err.body(), status_code=exc.status_code, headers=err.headers)
78
+
79
+ @app.exception_handler(RequestValidationError)
80
+ async def _validation_error(_: Request, exc: RequestValidationError):
81
+ detail = [{k: v for k, v in e.items() if k in ("loc", "msg", "type")} | {"loc": list(e.get("loc", ()))}
82
+ for e in exc.errors()]
83
+ return JSONResponse({"detail": detail}, status_code=422)
84
+
85
+ @app.exception_handler(Exception)
86
+ async def _unhandled(_: Request, exc: Exception):
87
+ err = errors.from_exception(exc)
88
+ return JSONResponse(err.body(), status_code=err.status)
89
+
90
+ # ---- helpers ----
91
+ def base_url(request: Request) -> str:
92
+ return settings.public_url or str(request.base_url).rstrip("/")
93
+
94
+ def submit(request: Request, type_: str, body: schemas._M, *, sync: bool) -> Any:
95
+ req = body.dump()
96
+ if type_ == "Edit":
97
+ wh = req.get("webhook") or {}
98
+ priority, metadata = bool(req.get("priority")), wh.get("metadata")
99
+ webhook = wh if wh.get("mode", "disabled") != "disabled" else None
100
+ source = req.get("document_url")
101
+ else:
102
+ a = req.get("async") or {}
103
+ priority, metadata, webhook = bool(a.get("priority")), a.get("metadata"), a.get("webhook")
104
+ source = req.get("input")
105
+ job = jobs.create(type_, req, sync=sync, priority=priority, queue_priority=req.get("queue_priority", "auto"),
106
+ metadata=metadata, webhook=None if sync else webhook, base_url=base_url(request),
107
+ source=source, raw_config=json.dumps(body.model_dump(by_alias=True, mode="json",
108
+ exclude_unset=True)))
109
+ if not sync:
110
+ worker.notify()
111
+ return JSONResponse({"job_id": job.id})
112
+ _, response, err = service.process(job)
113
+ if err is not None:
114
+ raise err
115
+ return JSONResponse(response)
116
+
117
+ def get_job(job_id: str) -> Job:
118
+ job = jobs.get(job_id)
119
+ if job is None:
120
+ raise errors.not_found(f"Job not found: {job_id}", "job_not_found")
121
+ if job.deleted_at is not None:
122
+ raise ApiGone(job_id)
123
+ return job
124
+
125
+ # ---- upload + file store ----
126
+ @app.post("/upload", dependencies=auth)
127
+ async def upload(request: Request, extension: Optional[str] = None):
128
+ upload_file = None
129
+ if request.headers.get("content-type", "").lower().startswith("multipart/form-data"):
130
+ form = await request.form()
131
+ candidate = form.get("file")
132
+ if hasattr(candidate, "file") and hasattr(candidate, "filename"):
133
+ upload_file = candidate
134
+ if upload_file is None:
135
+ rec = files.create("upload", None, extension=extension or "", ttl=settings.upload_ttl, pending=True)
136
+ url = f"{base_url(request)}/upload/presigned/{rec.id}?token={rec.token}"
137
+ return {"file_id": rec.file_id, "presigned_url": url}
138
+ try:
139
+ rec = await run_in_threadpool(
140
+ files.put, "upload", upload_file.file, upload_file.filename, extension=extension or "",
141
+ ttl=settings.upload_ttl, content_type=upload_file.content_type, max_bytes=settings.max_upload_bytes)
142
+ except OverflowError:
143
+ raise errors.ApiError(413, "DOCUMENT_TOO_LARGE", "File exceeds the direct upload limit "
144
+ f"({settings.max_upload_bytes // (1024 * 1024)} MB)", "file_too_large") from None
145
+ return {"file_id": rec.file_id, "presigned_url": None}
146
+
147
+ @app.put("/upload/presigned/{file_id}", include_in_schema=False)
148
+ async def presigned_put(file_id: str, request: Request, token: str = ""):
149
+ rec = files.get(file_id, include_pending=True)
150
+ if rec is None:
151
+ raise errors.not_found("Upload URL not found or expired", "file_not_found")
152
+ if not hmac.compare_digest(token.encode(), rec.token.encode()):
153
+ raise errors.ApiError(403, "AUTH_ERROR", "Invalid upload token", "permission_error")
154
+ size = 0
155
+ with open(rec.path, "wb") as fh:
156
+ async for chunk in request.stream():
157
+ size += len(chunk)
158
+ if size > 50 * settings.max_upload_bytes: # presigned route is the large-file path
159
+ raise errors.ApiError(413, "DOCUMENT_TOO_LARGE", "File is too large", "file_too_large")
160
+ fh.write(chunk)
161
+ files.finish(rec, size)
162
+ return JSONResponse({"file_id": rec.file_id})
163
+
164
+ @app.delete("/upload/{file_id:path}", dependencies=auth)
165
+ def delete_upload(file_id: str):
166
+ rec = files.get(file_id, include_pending=True)
167
+ if rec is None or rec.kind != "upload":
168
+ raise errors.not_found(f"File not found: {file_id}", "file_not_found")
169
+ files.delete(rec.id)
170
+ return {"file_id": rec.file_id}
171
+
172
+ @app.get("/files/{file_id}", include_in_schema=False)
173
+ def get_file(file_id: str, token: str = ""):
174
+ rec = files.get(file_id)
175
+ if rec is None or not rec.path.exists():
176
+ raise errors.not_found("File not found or expired", "file_not_found")
177
+ if not hmac.compare_digest(token.encode(), rec.token.encode()):
178
+ raise errors.ApiError(403, "AUTH_ERROR", "Invalid file token", "permission_error")
179
+ return FileResponse(rec.path, media_type=rec.content_type or "application/octet-stream",
180
+ filename=rec.filename)
181
+
182
+ # ---- parse / extract / split / classify / edit ----
183
+ @app.post("/parse", dependencies=auth)
184
+ def parse(request: Request, body: schemas.AsyncParseConfig):
185
+ # Reducto's /parse takes SyncParseConfig or AsyncParseConfig: an explicit `async` block queues the job
186
+ return submit(request, "Parse", body, sync="async_" not in body.model_fields_set)
187
+
188
+ @app.post("/parse_async", dependencies=auth)
189
+ def parse_async(request: Request, body: schemas.AsyncParseConfig):
190
+ return submit(request, "Parse", body, sync=False)
191
+
192
+ @app.post("/extract", dependencies=auth)
193
+ def extract(request: Request, body: schemas.AsyncExtractConfig):
194
+ return submit(request, "Extract", body, sync="async_" not in body.model_fields_set)
195
+
196
+ @app.post("/extract_async", dependencies=auth)
197
+ def extract_async(request: Request, body: schemas.AsyncExtractConfig):
198
+ return submit(request, "Extract", body, sync=False)
199
+
200
+ @app.post("/split", dependencies=auth)
201
+ def split(request: Request, body: schemas.SyncSplitConfig):
202
+ return submit(request, "Split", body, sync=True)
203
+
204
+ @app.post("/split_async", dependencies=auth)
205
+ def split_async(request: Request, body: schemas.AsyncSplitConfig):
206
+ return submit(request, "Split", body, sync=False)
207
+
208
+ @app.post("/classify", dependencies=auth)
209
+ def classify(request: Request, body: schemas.ClassifyConfig):
210
+ return submit(request, "Classify", body, sync=True)
211
+
212
+ @app.post("/classify_async", dependencies=auth)
213
+ def classify_async(request: Request, body: schemas.ClassifyConfig):
214
+ return submit(request, "Classify", body, sync=False)
215
+
216
+ @app.post("/edit", dependencies=auth)
217
+ def edit(request: Request, body: schemas.EditConfig):
218
+ return submit(request, "Edit", body, sync=True)
219
+
220
+ @app.post("/edit_async", dependencies=auth)
221
+ def edit_async(request: Request, body: schemas.AsyncEditConfig):
222
+ return submit(request, "Edit", body, sync=False)
223
+
224
+ @app.post("/pipeline", dependencies=auth, deprecated=True)
225
+ def pipeline(body: schemas.V3PipelineConfig):
226
+ raise errors.not_implemented(PIPELINE_GONE)
227
+
228
+ @app.post("/pipeline_async", dependencies=auth, deprecated=True)
229
+ def pipeline_async(body: schemas.V3AsyncPipelineConfig):
230
+ raise errors.not_implemented(PIPELINE_GONE)
231
+
232
+ # ---- jobs ----
233
+ @app.get("/job/{job_id}", dependencies=auth)
234
+ def retrieve_job(job_id: str):
235
+ job = get_job(job_id)
236
+ status = job.status if settings.job_status_detail else PUBLIC_STATUS[job.status]
237
+ result = jobs.read_result(job.id) if job.status == "Completed" else None
238
+ return {"status": status, "result": result, "progress": job.progress, "reason": job.reason,
239
+ "error": job.error, "type": job.type, "num_pages": job.num_pages, "total_pages": job.total_pages,
240
+ "source": job.source, "duration": job.duration, "created_at": _iso(job.created_at),
241
+ "raw_config": job.raw_config, "bucket": None}
242
+
243
+ @app.delete("/job/{job_id}", dependencies=auth, status_code=202)
244
+ def delete_job(job_id: str, include_persisted: bool = False):
245
+ job = get_job(job_id) # 404 unknown, 410 already deleted
246
+ if job.status in ACTIVE:
247
+ raise errors.ApiError(409, "JOB_NOT_COMPLETE", f"Job {job_id} is still {job.status}; cancel it or wait "
248
+ "for it to finish before deleting", "job_not_complete", job_id)
249
+ jobs.mark_deleted(job_id)
250
+ files.delete_for_job(job_id)
251
+ return {"job_id": job_id}
252
+
253
+ @app.post("/cancel/{job_id}", dependencies=auth)
254
+ def cancel_job(job_id: str):
255
+ get_job(job_id)
256
+ status = jobs.cancel(job_id)
257
+ if status in ("Completed", "Failed"):
258
+ raise errors.ApiError(409, "JOB_STATE_ERROR", f"Job {job_id} already finished ({status})",
259
+ "job_state_error", job_id)
260
+ return {}
261
+
262
+ @app.get("/jobs", dependencies=auth)
263
+ def list_jobs(exclude_configs: bool = False, cursor: Optional[str] = None,
264
+ limit: int = Query(default=100, ge=1, le=500)):
265
+ try:
266
+ page, nxt = jobs.list(limit=limit, cursor=cursor)
267
+ except Exception:
268
+ raise errors.bad_request("Invalid cursor", type="invalid_cursor") from None
269
+ return {"jobs": [{"job_id": j.id, "status": j.status, "type": j.type,
270
+ "raw_config": "" if exclude_configs else j.raw_config, "created_at": _iso(j.created_at),
271
+ "source": j.source, "num_pages": j.num_pages, "total_pages": j.total_pages,
272
+ "duration": j.duration, "bucket": None} for j in page],
273
+ "next_cursor": nxt}
274
+
275
+ # ---- misc ----
276
+ @app.post("/configure_webhook", dependencies=auth)
277
+ async def configure_webhook(request: Request, url: Optional[str] = None):
278
+ """Reducto returns its Svix portal URL. We have no portal: this stores (``?url=`` or ``{"url": ...}``)
279
+ and returns the default URL used for svix-mode webhooks."""
280
+ if url is None and request.headers.get("content-type", "").startswith("application/json"):
281
+ try:
282
+ data = await request.json()
283
+ url = data.get("url") if isinstance(data, dict) else None
284
+ except Exception:
285
+ url = None
286
+ if url is not None:
287
+ if url and not url.startswith(("http://", "https://")):
288
+ raise errors.bad_request("url must be http(s)", type="invalid_url")
289
+ db.set_setting(webhooks.DEFAULT_URL_KEY, url or None)
290
+ return JSONResponse(db.get_setting(webhooks.DEFAULT_URL_KEY) or settings.webhook_url or "")
291
+
292
+ @app.get("/version")
293
+ def version():
294
+ return JSONResponse(f"readanydoc-{__version__}")
295
+
296
+ @app.get("/health", include_in_schema=False)
297
+ def health():
298
+ return {"status": "ok", "version": __version__, "auth": bool(os.environ.get(API_KEY_ENV))}
299
+
300
+ return app
@@ -0,0 +1,72 @@
1
+ """Server settings. Everything comes from environment variables; nothing here is a secret.
2
+
3
+ The API key (``READANYDOC_API_KEY``) and the webhook signing secret (``READANYDOC_WEBHOOK_SECRET``) are read
4
+ from the environment at the moment they are needed and are never stored, logged or returned.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+
13
+ from .. import paths
14
+
15
+ API_KEY_ENV = "READANYDOC_API_KEY"
16
+ WEBHOOK_SECRET_ENV = "READANYDOC_WEBHOOK_SECRET"
17
+
18
+
19
+ def _f(name: str, default: float) -> float:
20
+ try:
21
+ return float(os.environ.get(name, default))
22
+ except ValueError:
23
+ return default
24
+
25
+
26
+ def _b(name: str, default: bool = False) -> bool:
27
+ v = os.environ.get(name)
28
+ return default if v is None else v.strip().lower() in ("1", "true", "yes", "on")
29
+
30
+
31
+ def _default_data_dir() -> Path:
32
+ return Path(os.environ.get("READANYDOC_DATA_DIR") or (paths.cache_dir() / "server"))
33
+
34
+
35
+ @dataclass
36
+ class ServerSettings:
37
+ data_dir: Path = field(default_factory=_default_data_dir)
38
+ # Base URL used in links we hand out (file URLs, presigned uploads). Empty = taken from each request.
39
+ public_url: str = field(default_factory=lambda: os.environ.get("READANYDOC_PUBLIC_URL", "").rstrip("/"))
40
+ upload_ttl: float = field(default_factory=lambda: _f("READANYDOC_UPLOAD_TTL_HOURS", 24) * 3600)
41
+ result_ttl: float = field(default_factory=lambda: _f("READANYDOC_RESULT_TTL_HOURS", 12) * 3600)
42
+ max_inline_bytes: int = field(default_factory=lambda: int(_f("READANYDOC_MAX_INLINE_BYTES", 6_000_000)))
43
+ max_upload_bytes: int = field(default_factory=lambda: int(_f("READANYDOC_MAX_UPLOAD_MB", 100) * 1024 * 1024))
44
+ # async jobs processed at once; pages of one document are read in parallel within each job
45
+ workers: int = field(default_factory=lambda: max(1, int(_f("READANYDOC_WORKERS", 4))))
46
+ # longest time one document may take (0 = no limit); a request that runs over fails with a timeout error
47
+ document_timeout: float = field(default_factory=lambda: _f("READANYDOC_DOCUMENT_TIMEOUT_SECONDS", 1800.0))
48
+ webhook_retries: int = field(default_factory=lambda: int(_f("READANYDOC_WEBHOOK_RETRIES", 3)))
49
+ webhook_backoff: float = field(default_factory=lambda: _f("READANYDOC_WEBHOOK_BACKOFF_SECONDS", 1.0))
50
+ webhook_timeout: float = field(default_factory=lambda: _f("READANYDOC_WEBHOOK_TIMEOUT_SECONDS", 10.0))
51
+ webhook_url: str = field(default_factory=lambda: os.environ.get("READANYDOC_WEBHOOK_URL", ""))
52
+ download_timeout: float = field(default_factory=lambda: _f("READANYDOC_DOWNLOAD_TIMEOUT_SECONDS", 60.0))
53
+ # Local file paths as `input` let a caller read any file the server can: off unless asked for.
54
+ allow_local_paths: bool = field(default_factory=lambda: _b("READANYDOC_ALLOW_LOCAL_PATHS"))
55
+ # document URLs that point at private or loopback addresses are refused (set to 0 to fetch from internal hosts)
56
+ block_private_urls: bool = field(default_factory=lambda: _b("READANYDOC_BLOCK_PRIVATE_URLS", True))
57
+ # GET /job reports only the statuses in Reducto's schema unless this is on (see docs/decisions.md).
58
+ job_status_detail: bool = field(default_factory=lambda: _b("READANYDOC_JOB_STATUS_DETAIL"))
59
+ janitor_interval: float = field(default_factory=lambda: _f("READANYDOC_JANITOR_SECONDS", 300.0))
60
+ poll_interval: float = 0.25
61
+ # ---- disk hygiene (store/cleanup.py); every limit is checked by the janitor, never against running jobs ----
62
+ # working caches (page renders, decrypted copies, figure crops, word caches); None = leave them alone
63
+ cache_dir: Path | None = field(default_factory=lambda: paths.cache_dir() if _b("READANYDOC_CLEAN_CACHE", True) else None)
64
+ cache_ttl: float = field(default_factory=lambda: _f("READANYDOC_CACHE_TTL_HOURS", 6) * 3600)
65
+ reused_cache_ttl: float = field(default_factory=lambda: _f("READANYDOC_REUSED_CACHE_TTL_DAYS", 14) * 86400)
66
+ max_cache_bytes: int = field(default_factory=lambda: int(_f("READANYDOC_MAX_CACHE_GB", 5) * 1024 ** 3))
67
+ max_data_bytes: int = field(default_factory=lambda: int(_f("READANYDOC_MAX_DATA_GB", 0) * 1024 ** 3))
68
+ clean_min_age: float = field(default_factory=lambda: _f("READANYDOC_CLEAN_MIN_AGE_SECONDS", 900.0))
69
+
70
+
71
+ def api_key_configured() -> bool:
72
+ return bool(os.environ.get(API_KEY_ENV))
@@ -0,0 +1,139 @@
1
+ """The call contract between the API layer and ``readanydoc.tasks``.
2
+
3
+ The API resolves inputs, runs the parse pipeline and hands each task a ``Document``; the task returns
4
+ a plain dict that the API wraps into Reducto's response (job id, duration, usage defaults, URL result).
5
+
6
+ Minimal form (what a replacement task or a test double needs):
7
+
8
+ readanydoc.tasks.extract.run(document, config) -> dict
9
+ readanydoc.tasks.split.run(document, config) -> dict
10
+ readanydoc.tasks.classify.run(document, config) -> dict
11
+ readanydoc.tasks.edit.run(input_path, config) -> dict
12
+
13
+ The tasks' published form adds keyword-only arguments; the adapter here inspects the signature
14
+ and supplies whichever of them ``run`` declares:
15
+
16
+ run(document, config, *, llm, options=None) extract / split / classify
17
+ run(document, config, *, llm, source_path, output_path=None, options=None) edit (document is None)
18
+
19
+ ``llm`` is ``readanydoc.tasks.llm.get_llm(os.environ.get("READANYDOC_TASK_LLM") or DEFAULT_LLM)``;
20
+ ``source_path`` is the local file to edit; ``output_path`` a path inside the job's output directory.
21
+
22
+ ``document``
23
+ ``readanydoc.model.Document`` (already parsed with the request's ``parsing`` options), or a
24
+ ``list[Document]`` when the request's ``input`` is a list (multi-document).
25
+ ``input_path`` (edit, minimal form only)
26
+ ``str`` path of the local file to edit (PDF or DOCX), already downloaded.
27
+ ``config``
28
+ The validated request body as a dict with every documented default filled in, keys exactly as in
29
+ Reducto's API (``instructions.schema``, ``settings.citations.enabled``, ``split_description``,
30
+ ``classification_schema``, ``edit_instructions``, ``form_schema`` ...). It still contains ``input``
31
+ / ``parsing`` / ``async`` (ignore them) and one extra key, ``"_readanydoc"``:
32
+ ``{"job_id": str, "profile": str, "output_dir": str}``; ``output_dir`` is a directory the task may
33
+ write files into.
34
+
35
+ Return value: a dict (or a pydantic model, which is dumped). Keys the API reads:
36
+
37
+ extract {"result": <object | list>, # required; with citations the task puts
38
+ "usage": {"num_fields": int, ...}?, # {"value", "citations"} leaves in result
39
+ "confidence": "high"|"low"?, "confidence_reason": str?}
40
+ split {"result": {"splits": [{"name", "pages", "conf", "partitions"?}], "section_mapping": {...}|None},
41
+ "usage": {...}?}
42
+ classify {"result": {"category": str}, "response_confidence": {"categories": [...]}?,
43
+ "extra_metadata": {str: str}?, "usage": {...}?}
44
+ edit {"document_url": <local output file path, or a ready http(s) URL>
45
+ (or "document_path": str, or "document_bytes": bytes), "filename": str?,
46
+ "form_schema": [EditWidget...]?, "usage": {...}?}
47
+
48
+ ``job_id``, ``duration`` and ``response_type`` are set by the API; missing ``usage`` fields get
49
+ defaults (``num_pages`` from the Document, ``num_fields`` by counting result leaves, ``credits`` null).
50
+ Anything else in the dict is passed through into the response untouched.
51
+
52
+ Errors: ``readanydoc.tasks.TaskError(status_code, detail)`` -> that status; ``NotImplementedError`` -> 501;
53
+ ``jsonschema`` SchemaError -> 422 INVALID_SCHEMA; ``ValueError`` -> 400 INVALID_CONFIG;
54
+ ``readanydoc.api.errors.ApiError`` -> as given; anything else -> 500. All in Reducto's error shape.
55
+ If ``readanydoc.tasks.<name>`` cannot be imported, or has no ``run``, the route answers 501.
56
+ """
57
+
58
+ from __future__ import annotations
59
+
60
+ import importlib
61
+ import inspect
62
+ import os
63
+ from pathlib import Path
64
+ from typing import Any, Callable
65
+
66
+ from . import errors
67
+
68
+ TASK_LLM_ENV = "READANYDOC_TASK_LLM"
69
+ TASKS = ("extract", "split", "classify", "edit")
70
+ TaskFn = Callable[[Any, dict[str, Any]], Any]
71
+
72
+ # tests (and embedders) can register a replacement here: OVERRIDES["extract"] = fn
73
+ OVERRIDES: dict[str, TaskFn] = {}
74
+
75
+
76
+ def load_task(name: str) -> TaskFn:
77
+ if name not in TASKS:
78
+ raise errors.not_implemented(f"unknown task {name!r}")
79
+ if name in OVERRIDES:
80
+ return OVERRIDES[name]
81
+ try:
82
+ mod = importlib.import_module(f"readanydoc.tasks.{name}")
83
+ except Exception as e: # ImportError, or the module failing at import while it is being written
84
+ raise errors.not_implemented(
85
+ f"/{name} is not available in this build: readanydoc.tasks.{name} could not be imported "
86
+ f"({e.__class__.__name__}: {e})") from e
87
+ fn = getattr(mod, "run", None)
88
+ if not callable(fn):
89
+ raise errors.not_implemented(f"/{name} is not available in this build: readanydoc.tasks.{name}.run is missing")
90
+ return fn
91
+
92
+
93
+ _STATUS_NAMES = {400: "INVALID_CONFIG", 401: "AUTH_ERROR", 404: "NOT_FOUND", 413: "DOCUMENT_TOO_LARGE",
94
+ 415: "DOCUMENT_UNSUPPORTED", 422: "INVALID_SCHEMA", 429: "RATE_LIMIT",
95
+ 442: "DOCUMENT_PASSWORD_PROTECTED", 501: "NOT_APPLICABLE", 502: "LLM_PROVIDER_ERROR",
96
+ 503: "SERVICE_UNAVAILABLE", 504: "TIMEOUT"}
97
+
98
+
99
+ def _keyword_args(fn: TaskFn, name: str, target: Any, config: dict[str, Any]) -> tuple[Any, dict[str, Any]]:
100
+ """Supply the keyword-only arguments of the task signature, if ``fn`` declares them."""
101
+ try:
102
+ params = inspect.signature(fn).parameters
103
+ except (TypeError, ValueError):
104
+ return target, {}
105
+ kwargs: dict[str, Any] = {}
106
+ if "llm" in params:
107
+ from ..tasks.llm import DEFAULT_LLM, get_llm # only reached when readanydoc.tasks exists
108
+ kwargs["llm"] = get_llm(os.environ.get(TASK_LLM_ENV) or DEFAULT_LLM)
109
+ if name == "edit" and "source_path" in params:
110
+ kwargs["source_path"] = target
111
+ if "output_path" in params:
112
+ out_dir = Path((config.get("_readanydoc") or {}).get("output_dir") or ".")
113
+ kwargs["output_path"] = str(out_dir / f"edited{Path(str(target)).suffix or '.pdf'}")
114
+ target = None
115
+ return target, kwargs
116
+
117
+
118
+ def call_task(name: str, target: Any, config: dict[str, Any]) -> dict[str, Any]:
119
+ fn = load_task(name)
120
+ try:
121
+ target, kwargs = _keyword_args(fn, name, target, config)
122
+ out = fn(target, config, **kwargs)
123
+ except errors.ApiError:
124
+ raise
125
+ except NotImplementedError as e:
126
+ raise errors.not_implemented(str(e) or f"/{name} is not implemented yet") from e
127
+ except Exception as e:
128
+ status = getattr(e, "status_code", None)
129
+ if isinstance(status, int) and hasattr(e, "detail"): # readanydoc.tasks.TaskError
130
+ raise errors.ApiError(status, _STATUS_NAMES.get(status, "PROCESSING_FAILED" if status < 500
131
+ else "INTERNAL_ERROR"), str(e.detail)) from e
132
+ if isinstance(e, ValueError) and not e.__class__.__module__.startswith("jsonschema"):
133
+ raise errors.bad_request(str(e)) from e
134
+ raise errors.from_exception(e) from e
135
+ if hasattr(out, "model_dump"):
136
+ out = out.model_dump(mode="json", by_alias=True)
137
+ if not isinstance(out, dict):
138
+ raise errors.internal(f"readanydoc.tasks.{name}.run returned {type(out).__name__}, expected a dict")
139
+ return out
@@ -0,0 +1,86 @@
1
+ """Errors in Reducto's shape.
2
+
3
+ Reducto's error-codes page shows ``{"error": {"message", "type", "code"}}``; FastAPI-style clients read
4
+ ``detail``. We send both, plus ``name`` (the ``ErrorCode`` enum of the OpenAPI spec) inside ``error``.
5
+ Failed jobs carry an ``ErrorDetail`` object: ``{code, name, message, job_id}``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Optional
11
+
12
+
13
+ class ApiError(Exception):
14
+ def __init__(self, status: int, name: str, message: str, type: Optional[str] = None,
15
+ job_id: Optional[str] = None, headers: Optional[dict[str, str]] = None):
16
+ super().__init__(message)
17
+ self.status = status
18
+ self.name = name # ErrorCode enum value
19
+ self.message = message
20
+ self.type = type or name.lower()
21
+ self.job_id = job_id
22
+ self.headers = headers
23
+
24
+ def body(self) -> dict[str, Any]:
25
+ err: dict[str, Any] = {"message": self.message, "type": self.type, "code": self.status, "name": self.name}
26
+ if self.job_id:
27
+ err["job_id"] = self.job_id
28
+ return {"error": err, "detail": self.message}
29
+
30
+ def detail(self, job_id: Optional[str] = None) -> dict[str, Any]:
31
+ """``ErrorDetail`` for GET /job."""
32
+ return {"code": self.status, "name": self.name, "message": self.message, "job_id": job_id or self.job_id}
33
+
34
+
35
+ def bad_request(message: str, name: str = "INVALID_CONFIG", type: str = "bad_request") -> ApiError:
36
+ return ApiError(400, name, message, type)
37
+
38
+
39
+ def download_failed(extra: str = "") -> ApiError:
40
+ msg = "Failed to download file from url. Please check the document url and try again"
41
+ return ApiError(400, "PROCESSING_FAILED", msg + (f" ({extra})" if extra else ""), "file_download_failure")
42
+
43
+
44
+ def not_found(message: str, type: str = "not_found") -> ApiError:
45
+ return ApiError(404, "NOT_FOUND", message, type)
46
+
47
+
48
+ def unauthorized() -> ApiError:
49
+ return ApiError(401, "AUTH_ERROR", "Invalid or missing API key. Send 'Authorization: Bearer <key>'.",
50
+ "authentication_error", headers={"WWW-Authenticate": "Bearer"})
51
+
52
+
53
+ def not_implemented(message: str) -> ApiError:
54
+ return ApiError(501, "NOT_APPLICABLE", message, "not_implemented")
55
+
56
+
57
+ def internal(message: str) -> ApiError:
58
+ return ApiError(500, "INTERNAL_ERROR", message, "internal_error")
59
+
60
+
61
+ def from_exception(exc: BaseException) -> ApiError:
62
+ """Map pipeline / task exceptions onto Reducto's status codes."""
63
+ if isinstance(exc, ApiError):
64
+ return exc
65
+ text = str(exc) or exc.__class__.__name__
66
+ cls = exc.__class__.__name__
67
+ low = text.lower()
68
+ if cls == "IntakeError":
69
+ if "password" in low:
70
+ return ApiError(442, "DOCUMENT_PASSWORD_PROTECTED",
71
+ "Document is password protected or the password is wrong. "
72
+ "Pass settings.document_password.", "password_protected")
73
+ if "not supported" in low:
74
+ return ApiError(415, "DOCUMENT_UNSUPPORTED", text, "file_conversion_error")
75
+ if "not found" in low:
76
+ return bad_request(text, "NOT_FOUND", "invalid_input")
77
+ return ApiError(415, "DOCUMENT_CORRUPT", text, "pdf_handling_error")
78
+ if isinstance(exc, NotImplementedError):
79
+ return not_implemented(text if str(exc) else "This operation is not implemented in this build.")
80
+ if cls in ("ReaderError", "LayoutError"):
81
+ return ApiError(502, "LLM_PROVIDER_ERROR", text, "llm_provider_error")
82
+ if cls in ("SchemaError", "ValidationError") and exc.__class__.__module__.startswith("jsonschema"):
83
+ return ApiError(422, "INVALID_SCHEMA", f"Invalid JSON schema: {getattr(exc, 'message', text)}", "invalid_schema")
84
+ if isinstance(exc, TimeoutError):
85
+ return ApiError(504, "TIMEOUT", text, "timeout")
86
+ return internal(f"{cls}: {text}")