| """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: |
| """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) |
| figures_info = [{"image": lazy, "page": 0, "bbox": None, "caption": ""}] |
| state["page_images"] = [lazy] |
| state["figures_info"] = figures_info |
| state["selected_figure"] = figures_info[0] |
| 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"] = [ |
| 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"]) |
| state["figures_info"] = figures_info |
| status_lines.append(f"Number of figures extracted: {len(figures_info)}.") |
|
|
| if figures_info: |
| state["selected_figure"] = figures_info[0] |
| 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: |
| """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: |
| """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: |
| 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: |
| """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: |
| import traceback |
|
|
| traceback.print_exc() |
| raise HTTPException(status_code=400, detail=str(e)) from e |
|
|