"""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 ( cleanup_session_files, get_temp_dir, load_parse_cache, save_image, save_parse_cache, save_upload, use_disk_images, ) def _current_fig_type(state: dict[str, Any]) -> "str | None": """Return the figure_type of the currently selected figure in a session state.""" figures_info = state.get("figures_info", []) idx = state.get("current_figure_index", 0) if figures_info and 0 <= idx < len(figures_info): return figures_info[idx].get("figure_type") return None 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, goto_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. goto_figure: Function to jump directly to a figure by index. """ @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") on_progress("Classifying image...") from api_routes_v1 import _classify_pil_image figure_type = _classify_pil_image(image) lazy = save_image(session_id, "figures", 0, image) # LazyImage or PIL Image figures_info = [{"image": lazy, "page": 0, "bbox": None, "caption": "", "figure_type": figure_type}] 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), "fig_type": figure_type, "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"] fig_type = figures_info[0].get("figure_type") else: state["selected_figure"] = None fig_status = "No figures found" fig_caption = "" fig_image = None fig_type = 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, "fig_type": fig_type, "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), "fig_type": _current_fig_type(session_states[session_id]), } ) except Exception as e: # noqa: BLE001 import traceback traceback.print_exc() raise HTTPException(status_code=400, detail=str(e)) from e @app.post("/api/goto_figure") async def api_goto_figure(session_id: str = Query(...), index: int = Query(...)) -> JSONResponse: # noqa: ANN001 """Jump directly to a specific figure for a session. Args: session_id: The session ID to select a figure for. index: Zero-based figure index to select. 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 = goto_figure(session_states[session_id], index) 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), "fig_type": _current_fig_type(session_states[session_id]), } ) except Exception as e: # noqa: BLE001 import traceback traceback.print_exc() raise HTTPException(status_code=400, detail=str(e)) from e @app.delete("/api/session/{session_id}") async def api_delete_session(session_id: str) -> JSONResponse: # noqa: ANN001 """Discard a session's in-memory state and any files it wrote to disk. Args: session_id: The session ID to discard. Returns: JSON response confirming deletion. """ if session_id not in session_states: raise HTTPException(status_code=404, detail="Session not found") session_states.pop(session_id, None) cleanup_session_files(session_id) return JSONResponse({"status": "deleted"}) @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), "fig_type": _current_fig_type(session_states[session_id]), } ) except Exception as e: # noqa: BLE001 import traceback traceback.print_exc() raise HTTPException(status_code=400, detail=str(e)) from e