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.
- readanydoc/__init__.py +12 -0
- readanydoc/api/__init__.py +6 -0
- readanydoc/api/__main__.py +26 -0
- readanydoc/api/app.py +300 -0
- readanydoc/api/config.py +72 -0
- readanydoc/api/contracts.py +139 -0
- readanydoc/api/errors.py +86 -0
- readanydoc/api/inputs.py +142 -0
- readanydoc/api/schemas.py +372 -0
- readanydoc/api/service.py +426 -0
- readanydoc/api/webhooks.py +90 -0
- readanydoc/api/worker.py +79 -0
- readanydoc/cli.py +140 -0
- readanydoc/cvsafe.py +60 -0
- readanydoc/intake/__init__.py +12 -0
- readanydoc/intake/common.py +94 -0
- readanydoc/intake/docx_reader.py +356 -0
- readanydoc/intake/errors.py +71 -0
- readanydoc/intake/heic.py +66 -0
- readanydoc/intake/legacy.py +144 -0
- readanydoc/intake/msg.py +123 -0
- readanydoc/intake/pdf.py +100 -0
- readanydoc/intake/pptx_reader.py +114 -0
- readanydoc/intake/prepare.py +510 -0
- readanydoc/intake/sheets.py +425 -0
- readanydoc/intake/sniff.py +96 -0
- readanydoc/intake/text.py +103 -0
- readanydoc/layout/__init__.py +51 -0
- readanydoc/layout/base.py +51 -0
- readanydoc/layout/cached.py +61 -0
- readanydoc/layout/claude.py +101 -0
- readanydoc/layout/onnx.py +285 -0
- readanydoc/library.py +296 -0
- readanydoc/localmodels.py +83 -0
- readanydoc/model/__init__.py +42 -0
- readanydoc/model/document.py +280 -0
- readanydoc/modelspec.py +79 -0
- readanydoc/paths.py +53 -0
- readanydoc/pipeline/__init__.py +23 -0
- readanydoc/pipeline/assemble.py +70 -0
- readanydoc/pipeline/assembly/__init__.py +1 -0
- readanydoc/pipeline/assembly/annotations.py +69 -0
- readanydoc/pipeline/assembly/furniture.py +255 -0
- readanydoc/pipeline/assembly/headings.py +261 -0
- readanydoc/pipeline/assembly/paragraphs.py +88 -0
- readanydoc/pipeline/assembly/tables.py +320 -0
- readanydoc/pipeline/base.py +178 -0
- readanydoc/pipeline/checks/__init__.py +97 -0
- readanydoc/pipeline/checks/common.py +66 -0
- readanydoc/pipeline/checks/complete.py +426 -0
- readanydoc/pipeline/checks/flags.py +62 -0
- readanydoc/pipeline/checks/gridrepair.py +128 -0
- readanydoc/pipeline/checks/htmlgrid.py +288 -0
- readanydoc/pipeline/checks/numbers.py +318 -0
- readanydoc/pipeline/checks/reread.py +92 -0
- readanydoc/pipeline/common.py +38 -0
- readanydoc/pipeline/figures/__init__.py +530 -0
- readanydoc/pipeline/figures/layout_pictures.py +153 -0
- readanydoc/pipeline/figures/locate.py +212 -0
- readanydoc/pipeline/forms/__init__.py +99 -0
- readanydoc/pipeline/forms/acroform.py +80 -0
- readanydoc/pipeline/forms/checkbox.py +136 -0
- readanydoc/pipeline/forms/fillable.py +303 -0
- readanydoc/pipeline/forms/grid.py +293 -0
- readanydoc/pipeline/forms/lines.py +100 -0
- readanydoc/pipeline/forms/locate.py +135 -0
- readanydoc/pipeline/forms/region.py +159 -0
- readanydoc/pipeline/forms/rules.py +105 -0
- readanydoc/pipeline/ground.py +720 -0
- readanydoc/pipeline/grounding/__init__.py +1 -0
- readanydoc/pipeline/grounding/align.py +270 -0
- readanydoc/pipeline/grounding/confidence.py +66 -0
- readanydoc/pipeline/grounding/ocr.py +204 -0
- readanydoc/pipeline/grounding/sources.py +274 -0
- readanydoc/pipeline/grounding/tables.py +70 -0
- readanydoc/pipeline/grounding/tokens.py +165 -0
- readanydoc/pipeline/intake.py +286 -0
- readanydoc/pipeline/layout.py +63 -0
- readanydoc/pipeline/mdblocks.py +128 -0
- readanydoc/pipeline/read.py +925 -0
- readanydoc/pipeline/reading/__init__.py +7 -0
- readanydoc/pipeline/reading/budget.py +150 -0
- readanydoc/pipeline/reading/effort_route.py +49 -0
- readanydoc/pipeline/reading/escalate.py +71 -0
- readanydoc/pipeline/reading/evidence.py +101 -0
- readanydoc/pipeline/reading/form_prefetch.py +84 -0
- readanydoc/pipeline/reading/formulas.py +137 -0
- readanydoc/pipeline/reading/fusion.py +128 -0
- readanydoc/pipeline/reading/handwriting.py +133 -0
- readanydoc/pipeline/reading/robust.py +142 -0
- readanydoc/pipeline/reading/select.py +55 -0
- readanydoc/pipeline/reading/strips.py +129 -0
- readanydoc/pipeline/reading/tables.py +409 -0
- readanydoc/pipeline/reading/zoom.py +158 -0
- readanydoc/pipeline/reading/zoom_gate.py +91 -0
- readanydoc/pipeline/reading/zoom_regions.py +72 -0
- readanydoc/pipeline/reading/zoom_schedule.py +67 -0
- readanydoc/pipeline/regions/__init__.py +207 -0
- readanydoc/pipeline/regions/assemble.py +169 -0
- readanydoc/pipeline/regions/complete.py +103 -0
- readanydoc/pipeline/regions/crops.py +209 -0
- readanydoc/pipeline/regions/fields.py +580 -0
- readanydoc/pipeline/regions/formevidence.py +187 -0
- readanydoc/pipeline/regions/forms.py +114 -0
- readanydoc/pipeline/regions/furniture.py +614 -0
- readanydoc/pipeline/regions/match.py +162 -0
- readanydoc/pipeline/regions/structure.py +264 -0
- readanydoc/pipeline/regions/tables.py +164 -0
- readanydoc/pipeline/request.py +72 -0
- readanydoc/pipeline/shape.py +260 -0
- readanydoc/pipeline/shaping/__init__.py +0 -0
- readanydoc/pipeline/shaping/agentic.py +215 -0
- readanydoc/pipeline/shaping/chunking.py +339 -0
- readanydoc/pipeline/shaping/embed.py +82 -0
- readanydoc/pipeline/shaping/merge.py +89 -0
- readanydoc/pipeline/shaping/options.py +129 -0
- readanydoc/pipeline/shaping/page_range.py +49 -0
- readanydoc/pipeline/shaping/response.py +112 -0
- readanydoc/pipeline/shaping/searchable.py +248 -0
- readanydoc/pipeline/shaping/summarize.py +74 -0
- readanydoc/pipeline/shaping/tables.py +414 -0
- readanydoc/pipeline/tables/__init__.py +145 -0
- readanydoc/pipeline/tables/evidence.py +163 -0
- readanydoc/pipeline/tables/hygiene.py +151 -0
- readanydoc/pipeline/tables/prefetch.py +116 -0
- readanydoc/pipeline/tables/reread.py +462 -0
- readanydoc/pipeline/triage.py +188 -0
- readanydoc/pipeline/typing/__init__.py +13 -0
- readanydoc/pipeline/typing/layout.py +217 -0
- readanydoc/pipeline/typing/markup.py +190 -0
- readanydoc/pipeline/verify.py +287 -0
- readanydoc/profiles.py +115 -0
- readanydoc/readers/__init__.py +177 -0
- readanydoc/readers/anthropic_api.py +165 -0
- readanydoc/readers/anthropic_batch.py +191 -0
- readanydoc/readers/base.py +116 -0
- readanydoc/readers/batch_mode.py +28 -0
- readanydoc/readers/cached.py +109 -0
- readanydoc/readers/claude_cli.py +73 -0
- readanydoc/readers/crop_cache.py +75 -0
- readanydoc/readers/live_cache.py +88 -0
- readanydoc/readers/ocr_words.py +68 -0
- readanydoc/readers/openai_compatible.py +94 -0
- readanydoc/readers/prompts.py +112 -0
- readanydoc/readers/sizing.py +198 -0
- readanydoc/readers/usage.py +111 -0
- readanydoc/serialize/__init__.py +3 -0
- readanydoc/serialize/citations.py +134 -0
- readanydoc/serialize/reducto.py +120 -0
- readanydoc/store/__init__.py +8 -0
- readanydoc/store/cleanup.py +279 -0
- readanydoc/store/db.py +61 -0
- readanydoc/store/files.py +153 -0
- readanydoc/store/jobs.py +223 -0
- readanydoc/tasks/__init__.py +51 -0
- readanydoc/tasks/classify.py +137 -0
- readanydoc/tasks/common.py +223 -0
- readanydoc/tasks/detect.py +274 -0
- readanydoc/tasks/edit.py +1089 -0
- readanydoc/tasks/extract.py +1049 -0
- readanydoc/tasks/extract_extras.py +325 -0
- readanydoc/tasks/llm.py +477 -0
- readanydoc/tasks/schema.py +473 -0
- readanydoc/tasks/split.py +358 -0
- readanydoc/textnorm.py +78 -0
- readanydoc-0.1.0.dist-info/METADATA +206 -0
- readanydoc-0.1.0.dist-info/RECORD +170 -0
- readanydoc-0.1.0.dist-info/WHEEL +4 -0
- readanydoc-0.1.0.dist-info/entry_points.txt +2 -0
- 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,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
|
readanydoc/api/config.py
ADDED
|
@@ -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
|
readanydoc/api/errors.py
ADDED
|
@@ -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}")
|