patristic-be / src /api /routers /samples.py
Mario33333's picture
deploy: reader-access security hardening (feature/audiobook@06a5ed8)
7c2d250 verified
Raw
History Blame Contribute Delete
6.49 kB
"""OCR-sample preview for the Add Book wizard.
Before committing to a full ingest, the wizard can OCR + clean the first N
pages of an uploaded PDF and show a per-page *image | native text | OCR |
cleaned* comparison, so the user decides the extraction mode with eyes open.
- ``POST /books/ocr-sample`` body ``{uploadId, pages}`` → 202 ``{jobId, sseUrl}``.
Runs the ``ocr_sample`` job (paid; SSE-tracked). Admin-only.
- ``GET /uploads/{uploadId}/pages/{n}/image?dpi=`` → PNG of one page of the
*uploaded* (not-yet-ingested) PDF, rendered on demand.
- ``GET /uploads/{uploadId}/ocr-sample`` → per-page OCR + cleaned text read from
the job's scratch ``__probe__{uploadId[:12]}`` dirs. Empty ``pages`` until the
job has run.
The scratch dirs are keyed by ``upload_id`` (see ``jobs/types/ocr_sample.py``),
so these reads need only the upload id — no sha/probe-id indirection on the FE.
"""
from __future__ import annotations
import json
import logging
import uuid
from pathlib import Path as PathLib
from fastapi import APIRouter, Depends, Path, Query
from fastapi.responses import Response
from src.api.deps import require_admin
from src.api.dto.common import ApiModel, JobStartResponse
from src.api.errors import BadRequest
from src.api.jobs import runner, store
from src.api.limits import enforce_paid_request
from src.api.validation import require_safe_id
from src.config import load_config
from src.lib.auth.models import User
from src.lib.probe_ocr import DEFAULT_SAMPLE_PAGES, MAX_SAMPLE_PAGES, probe_id_for
# Side-effect import: registers the "ocr_sample" handler with the job registry.
from src.api.jobs.types import ocr_sample as _ocr_sample_job # noqa: F401
logger = logging.getLogger(__name__)
router = APIRouter(tags=["samples"])
class OcrSampleRequest(ApiModel):
"""Body for ``POST /books/ocr-sample``."""
upload_id: str
pages: int = DEFAULT_SAMPLE_PAGES
def _upload_path(upload_id: str) -> PathLib:
# Validate before the join so a crafted id can't escape the uploads dir.
require_safe_id(upload_id, field="uploadId")
return load_config().paths.data_dir / "uploads" / f"{upload_id}.pdf"
@router.post("/books/ocr-sample", status_code=202, response_model=JobStartResponse)
def post_ocr_sample(
payload: OcrSampleRequest,
user: User = Depends(require_admin),
) -> JobStartResponse:
"""Kick off an OCR-sample job for an uploaded PDF. Returns 202 + job."""
enforce_paid_request(user) # daily spend ceiling + per-user rate limit
if not _upload_path(payload.upload_id).exists():
raise BadRequest(
f"No upload with id {payload.upload_id!r}. Re-upload and try again."
)
pages = max(1, min(int(payload.pages or DEFAULT_SAMPLE_PAGES), MAX_SAMPLE_PAGES))
params = {"upload_id": payload.upload_id, "pages": pages}
job_id = uuid.uuid4().hex
store.insert_job(
id=job_id,
type="ocr_sample",
params_json=params,
username=user.username,
subject_id=payload.upload_id,
)
runner.enqueue(
job_id,
type_="ocr_sample",
params=params,
subject_key=f"probe:{payload.upload_id}",
)
logger.info("ocr_sample enqueued: job=%s upload=%s pages=%s", job_id, payload.upload_id, pages)
return JobStartResponse(job_id=job_id, sse_url=f"/jobs/{job_id}/events")
@router.get("/uploads/{uploadId}/pages/{n}/image")
def get_upload_page_image(
upload_id: str = Path(..., alias="uploadId"),
n: int = Path(..., ge=1),
dpi: int = Query(default=150, ge=72, le=300),
_: User = Depends(require_admin),
) -> Response:
"""PNG of one page of an uploaded (pre-ingest) PDF. Rendered on demand."""
path = _upload_path(upload_id)
if not path.exists():
raise BadRequest(f"No upload with id {upload_id!r}.")
import fitz # local import keeps PyMuPDF out of module import cost
try:
with fitz.open(path) as doc:
if n > doc.page_count:
raise BadRequest(f"Upload {upload_id!r} has only {doc.page_count} pages.")
zoom = dpi / 72
pix = doc.load_page(n - 1).get_pixmap(matrix=fitz.Matrix(zoom, zoom), alpha=False)
png = pix.tobytes("png")
except BadRequest:
raise
except Exception as e: # noqa: BLE001
raise BadRequest(f"Could not render page {n} of upload {upload_id!r}: {e}")
return Response(content=png, media_type="image/png",
headers={"Cache-Control": "private, max-age=3600"})
@router.get("/uploads/{uploadId}/ocr-sample")
def get_ocr_sample(
upload_id: str = Path(..., alias="uploadId"),
_: User = Depends(require_admin),
) -> dict:
"""Per-page OCR + cleaned text from the sample job's scratch dirs.
Returns ``{uploadId, pages: [...]}``. ``pages`` is empty until the
``ocr_sample`` job has produced scratch output. camelCase keys are
written directly (no DTO round-trip needed).
"""
require_safe_id(upload_id, field="uploadId")
cfg = load_config()
probe_id = probe_id_for(upload_id)
ocr_dir = cfg.paths.ocr_dir / probe_id
clean_dir = cfg.paths.clean_dir / probe_id
page_nums: set[int] = set()
for d in (ocr_dir, clean_dir):
if d.exists():
for f in d.glob("page_*.json"):
try:
page_nums.add(int(f.stem.split("_")[1]))
except (IndexError, ValueError):
continue
pages: list[dict] = []
for n in sorted(page_nums):
ocr_blob: dict = {}
clean_blob: dict = {}
op = ocr_dir / f"page_{n:04d}.json"
cp = clean_dir / f"page_{n:04d}.json"
if op.exists():
ocr_blob = json.loads(op.read_text(encoding="utf-8"))
if cp.exists():
clean_blob = json.loads(cp.read_text(encoding="utf-8"))
pages.append({
"pdfPage": n,
"ocrText": ocr_blob.get("text") or "",
"cleanText": clean_blob.get("text_clean") or "",
"uncertainWords": list(ocr_blob.get("uncertain_words") or []),
"cleanupWarnings": list(clean_blob.get("cleanup_warnings") or []),
"ocrEngine": ocr_blob.get("ocr_engine"),
"cleanupEngine": clean_blob.get("cleanup_engine"),
})
return {"uploadId": upload_id, "pages": pages}