Deploybot
Deploy from stable branch
082393b
Raw
History Blame Contribute Delete
10.8 kB
"""API routes for document intelligence endpoints.
Includes file upload, figure navigation, and helper function endpoints.
All endpoints work with session-based state management.
"""
import base64
import os
import uuid
from io import BytesIO
from pathlib import Path
from typing import Any
from fastapi import File, HTTPException, Query, UploadFile
from fastapi.responses import JSONResponse, StreamingResponse
from api_helpers import _stream_with_progress
from storage import get_temp_dir, load_parse_cache, save_image, save_parse_cache, save_upload, use_disk_images
def _image_to_b64(img: "Path | Image.Image | None") -> "str | None":
"""Encode a PIL Image or Path to a base64 PNG string for JSON responses."""
if img is None:
return None
if isinstance(img, Path):
return base64.b64encode(img.read_bytes()).decode("utf-8")
buf = BytesIO()
img.save(buf, format="PNG")
buf.seek(0)
return base64.b64encode(buf.getvalue()).decode("utf-8")
def create_document_routes(
app: Any,
session_states: dict[str, dict[str, Any]],
process_upload: Any,
next_figure: Any,
prev_figure: Any,
) -> None:
"""Register document processing API routes on the FastAPI app.
Args:
app: FastAPI application instance.
session_states: Global session states dictionary.
process_upload: Function to process uploaded files.
next_figure: Function to get next figure.
prev_figure: Function to get previous figure.
"""
@app.post("/api/upload")
async def api_upload_file(file: UploadFile = File(...)) -> StreamingResponse: # noqa: ANN001
"""API endpoint for file upload from Next.js frontend.
Args:
file: Uploaded file from form-data.
Returns:
SSE stream with progress events and final JSON result.
"""
from collections.abc import Callable
from PIL import Image
from crops import extract_figures
from document_parser import parse_document
from pdf_io import load_pdf_pages
from ui_state import create_initial_state, hash_bytes, page_cache, parse_cache
file_bytes = await file.read()
session_id = str(uuid.uuid4())
session_states[session_id] = create_initial_state()
suffix = Path(file.filename).suffix if file.filename else ".tmp"
upload_path = save_upload(session_id, file_bytes, suffix)
if upload_path is not None:
temp_path = str(upload_path)
_cleanup_temp = False
else:
temp_path = str(get_temp_dir() / f"{uuid.uuid4().hex}{suffix}")
Path(temp_path).write_bytes(file_bytes)
_cleanup_temp = True
image_exts = {".jpg", ".jpeg", ".jfif", ".png", ".bmp", ".dib", ".gif", ".tif", ".tiff", ".webp"}
office_exts = {".docx", ".xlsx", ".pptx"}
max_pages = 20
def _work(on_progress: Callable[[str], None]) -> dict:
try:
ext = Path(temp_path).suffix.lower()
state = session_states[session_id]
state["current_figure_index"] = 0
state["conversation_history"] = []
state["current_image_path"] = None
with open(temp_path, "rb") as f:
raw = f.read()
file_hash = hash_bytes(raw)
state["uploaded_file_hash"] = file_hash
if ext in image_exts:
on_progress("Loading image...")
image = Image.open(temp_path).convert("RGB")
lazy = save_image(session_id, "figures", 0, image) # LazyImage or PIL Image
figures_info = [{"image": lazy, "page": 0, "bbox": None, "caption": ""}]
state["page_images"] = [lazy] # LazyImage proxies in disk mode, PIL Images in memory mode
state["figures_info"] = figures_info # fig["image"] is LazyImage or PIL Image
state["selected_figure"] = figures_info[0] # reference to figures_info entry
session_states[session_id] = state
return {
"status": "Image loaded successfully.\nNumber of figures: 1.",
"html_content": "Image uploaded directly (no document parsing needed)",
"fig_status": "Figure 1 of 1 (Page 1)",
"fig_caption": "",
"fig_image": _image_to_b64(image),
"session_id": session_id,
}
fmt_label = ext.lstrip(".").upper()
status_lines = [f"{fmt_label} loaded successfully."]
if ext in office_exts:
page_images: list = []
state["page_images"] = []
else:
on_progress("Rendering PDF pages...")
cache_key = f"{file_hash}_{max_pages}"
if cache_key in page_cache:
page_images = page_cache[cache_key]
else:
page_images = load_pdf_pages(raw, max_pages=max_pages)
if not use_disk_images():
page_cache[cache_key] = page_images
state["page_images"] = [ # LazyImage proxies in disk mode, PIL Images in memory mode
save_image(session_id, "pages", i, img)
for i, img in enumerate(page_images)
]
status_lines.append(f"Number of pages rendered: {len(page_images)} (max {max_pages}).")
on_progress("Parsing document with Docling...")
if not use_disk_images() and file_hash in parse_cache:
parse_result = parse_cache[file_hash]
else:
parse_result = load_parse_cache(file_hash, session_id=session_id)
if parse_result is None:
parse_result = parse_document(raw, file_ext=ext, on_progress=on_progress)
save_parse_cache(file_hash, parse_result, session_id=session_id)
if not use_disk_images():
parse_cache[file_hash] = parse_result
status_lines.append("Document parsing done using Docling.")
on_progress("Extracting figures...")
figures_info = extract_figures(page_images, parse_result.get("figures", []))
for i, fig in enumerate(figures_info):
fig["image"] = save_image(session_id, "figures", i, fig["image"]) # LazyImage or PIL Image
state["figures_info"] = figures_info # fig["image"] is LazyImage or PIL Image
status_lines.append(f"Number of figures extracted: {len(figures_info)}.")
if figures_info:
state["selected_figure"] = figures_info[0] # reference to figures_info entry
fig_status = f"Figure 1 of {len(figures_info)} (Page {figures_info[0]['page'] + 1})"
fig_caption = figures_info[0].get("caption", "No caption")
fig_image = figures_info[0]["image"]
else:
state["selected_figure"] = None
fig_status = "No figures found"
fig_caption = ""
fig_image = None
on_progress("Finalizing...")
session_states[session_id] = state
fig_image_data = _image_to_b64(fig_image)
return {
"status": "\n".join(status_lines),
"html_content": parse_result.get("html", "No content available"),
"fig_status": fig_status,
"fig_caption": fig_caption,
"fig_image": fig_image_data,
"session_id": session_id,
}
finally:
if _cleanup_temp:
os.remove(temp_path)
return StreamingResponse(
_stream_with_progress(_work, session_id),
media_type="text/event-stream",
)
@app.get("/health")
async def health_check() -> JSONResponse: # noqa: ANN001
"""Health check endpoint to verify API is accessible.
Returns:
JSON response with status.
"""
return JSONResponse({"status": "ok"})
@app.post("/api/next_figure")
async def api_next_figure(session_id: str = Query(...)) -> JSONResponse: # noqa: ANN001
"""Get the next figure for a session.
Args:
session_id: The session ID to advance figures for.
Returns:
JSON response with figure info, caption, and image data.
"""
if session_id not in session_states:
raise HTTPException(status_code=404, detail="Session not found")
try:
fig_status, fig_caption, fig_image, updated_state = next_figure(session_states[session_id])
session_states[session_id] = updated_state
return JSONResponse(
{
"fig_status": fig_status,
"fig_caption": fig_caption,
"fig_image": _image_to_b64(fig_image),
"fig_index": session_states[session_id].get("current_figure_index", 0),
}
)
except Exception as e: # noqa: BLE001
import traceback
traceback.print_exc()
raise HTTPException(status_code=400, detail=str(e)) from e
@app.post("/api/prev_figure")
async def api_prev_figure(session_id: str = Query(...)) -> JSONResponse: # noqa: ANN001
"""Get the previous figure for a session.
Args:
session_id: The session ID to go back figures for.
Returns:
JSON response with figure info, caption, and image data.
"""
if session_id not in session_states:
raise HTTPException(status_code=404, detail="Session not found")
try:
fig_status, fig_caption, fig_image, updated_state = prev_figure(session_states[session_id])
session_states[session_id] = updated_state
return JSONResponse(
{
"fig_status": fig_status,
"fig_caption": fig_caption,
"fig_image": _image_to_b64(fig_image),
"fig_index": session_states[session_id].get("current_figure_index", 0),
}
)
except Exception as e: # noqa: BLE001
import traceback
traceback.print_exc()
raise HTTPException(status_code=400, detail=str(e)) from e