Spaces:
Running on Zero
Running on Zero
| """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. | |
| """ | |
| 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", | |
| ) | |
| async def health_check() -> JSONResponse: # noqa: ANN001 | |
| """Health check endpoint to verify API is accessible. | |
| Returns: | |
| JSON response with status. | |
| """ | |
| return JSONResponse({"status": "ok"}) | |
| 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 | |
| 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 | |