Spaces:
Runtime error
Runtime error
| """Paper endpoints: upload, list, detail, progress, delete.""" | |
| import io | |
| import os | |
| import shutil | |
| import traceback | |
| from uuid import UUID, uuid4 | |
| import aiofiles | |
| from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException | |
| from fastapi.responses import FileResponse, StreamingResponse | |
| from sqlalchemy import text | |
| from sqlalchemy.ext.asyncio import AsyncSession | |
| from app.api.deps import get_db, get_settings | |
| from app.api.errors import DocumentNotFound | |
| from app.core.config import Settings | |
| from app.core.logging import get_logger | |
| from app.core.paths import documents_dir, assets_dir, extracted_dir, images_dir, ensure_storage_dirs | |
| from app.schemas.documents import DocumentResponse, DocumentListResponse, DocumentUploadResponse | |
| from app.services import documents as doc_service | |
| from app.services.ingestion import create_ingestion_job, update_job_status as update_job_status_svc | |
| from app.database.repositories.documents import ( | |
| update_document_status as update_doc_status_repo, | |
| store_document_raw_content, | |
| get_document_raw_content, | |
| ) | |
| from app.workers.tasks import process_ingestion, embed_document, generate_section_summaries, reconstruct_reading_order | |
| logger = get_logger(__name__) | |
| router = APIRouter() | |
| async def upload_paper( | |
| file: UploadFile = File(...), | |
| kind: str = Form("paper"), | |
| db: AsyncSession = Depends(get_db), | |
| settings: Settings = Depends(get_settings), | |
| ): | |
| """Upload a PDF and dispatch ingestion to Celery worker. | |
| ``kind`` is ``"book"`` (chapter-by-chapter reading) or ``"paper"`` (linear). | |
| """ | |
| doc_kind = kind if kind in ("book", "paper") else "paper" | |
| max_bytes = settings.max_upload_size_mb * 1024 * 1024 | |
| ext = ".pdf" | |
| filename = f"{uuid4().hex}{ext}" | |
| dest = documents_dir() / filename | |
| content = await file.read() | |
| if len(content) > max_bytes: | |
| raise HTTPException( | |
| status_code=413, | |
| detail=f"File too large ({len(content) / (1024*1024):.1f} MB). Maximum allowed is {settings.max_upload_size_mb} MB.", | |
| ) | |
| # Reject anything that is not actually a PDF before it touches disk or the | |
| # extraction pipeline. The PDF spec requires the %PDF- header within the | |
| # first 1024 bytes; checking content (not the filename) blocks disguised | |
| # uploads (e.g. an executable renamed to .pdf). | |
| if b"%PDF-" not in content[:1024]: | |
| raise HTTPException( | |
| status_code=415, | |
| detail="Only PDF files are accepted (the uploaded file has no PDF header).", | |
| ) | |
| # Defensive: ensure the storage directories exist right before we write. | |
| # (lifespan does this, but this protects against CWD differences, docker vs local runs, etc.) | |
| try: | |
| ensure_storage_dirs() | |
| except Exception: | |
| logger.exception("ensure_storage_dirs failed") | |
| display_name = file.filename or "unknown.pdf" | |
| try: | |
| async with aiofiles.open(dest, "wb") as f: | |
| await f.write(content) | |
| original_name = display_name | |
| doc = await doc_service.create_document( | |
| db, | |
| filename=filename, | |
| original_filename=original_name, | |
| file_size_bytes=len(content), | |
| doc_kind=doc_kind, | |
| ) | |
| await db.commit() | |
| # Save raw copy to assets/<doc_id>.pdf (for /raw download + /static/assets). | |
| raw_path = assets_dir() / f"{doc['id']}.pdf" | |
| async with aiofiles.open(raw_path, "wb") as f: | |
| await f.write(content) | |
| # Persist the original bytes in Postgres so raw files survive the | |
| # ephemeral HF Space container disk. | |
| await store_document_raw_content(db, doc["id"], content) | |
| await db.commit() | |
| job = await create_ingestion_job(db, doc["id"]) | |
| await db.commit() | |
| # Dispatch to Celery... | |
| dispatch_ok = True | |
| try: | |
| task = process_ingestion.delay(str(doc["id"]), str(job["id"]), filename) # type: ignore[attr-defined] | |
| await update_job_status_svc( | |
| db, | |
| job["id"], | |
| "queued", | |
| celery_task_id=task.id, | |
| ) | |
| await db.commit() | |
| except Exception as dispatch_exc: | |
| logger.exception(f"Failed to dispatch process_ingestion for {doc['id']}") | |
| dispatch_ok = False | |
| try: | |
| await update_doc_status_repo( | |
| db, | |
| doc["id"], | |
| "failed", | |
| error_message=( | |
| "Failed to queue ingestion task (Celery broker / Redis unreachable). " | |
| "Start Redis (e.g. via docker compose or redis-server) and the Celery worker " | |
| f"(`celery -A app.core.celery_app worker`). Original error: {dispatch_exc}" | |
| ), | |
| ) | |
| await update_job_status_svc( | |
| db, | |
| job["id"], | |
| "failed", | |
| error_message=f"Dispatch failed: {dispatch_exc}", | |
| ) | |
| await db.commit() | |
| except Exception as mark_exc: | |
| logger.error(f"Failed to record dispatch failure for doc {doc['id']}: {mark_exc}") | |
| return DocumentUploadResponse( | |
| id=doc["id"], | |
| filename=filename, | |
| status="processing" if dispatch_ok else "failed", | |
| message=( | |
| "Document uploaded and queued for processing" | |
| if dispatch_ok | |
| else "Document recorded but background ingestion could not be queued (Redis/Celery). See error details." | |
| ), | |
| ) | |
| except HTTPException: | |
| raise | |
| except Exception as exc: | |
| # Safety net for DB/write errors early in upload. The full traceback is | |
| # logged server-side only — returning it to the client would leak | |
| # filesystem paths, connection details, and internal code structure. | |
| logger.error(f"Upload pipeline failed for {display_name}:\n{traceback.format_exc()}") | |
| raise HTTPException( | |
| status_code=500, | |
| detail=f"Upload failed: {type(exc).__name__}. Check the server logs for the full traceback.", | |
| ) from exc | |
| async def download_raw_paper( | |
| paper_id: UUID, | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """Download the original raw PDF file. | |
| Filesystem copies are preferred for speed, but if the ephemeral Space disk | |
| has been wiped we stream the persisted bytes from Postgres. | |
| """ | |
| doc = await doc_service.get_document(db, paper_id) | |
| if not doc: | |
| raise DocumentNotFound(str(paper_id)) | |
| raw_path = assets_dir() / f"{paper_id}.pdf" | |
| if not raw_path.exists(): | |
| # Fallback: try from documents dir | |
| raw_path = documents_dir() / doc["filename"] | |
| if raw_path.exists(): | |
| return FileResponse( | |
| path=str(raw_path), | |
| filename=doc["original_filename"], | |
| media_type="application/pdf", | |
| content_disposition_type="inline", | |
| ) | |
| raw_content = await get_document_raw_content(db, paper_id) | |
| if raw_content: | |
| return StreamingResponse( | |
| io.BytesIO(raw_content), | |
| media_type="application/pdf", | |
| headers={ | |
| "Content-Disposition": f'inline; filename="{doc["original_filename"]}"' | |
| }, | |
| ) | |
| raise DocumentNotFound(str(paper_id)) | |
| async def list_papers( | |
| limit: int = 50, | |
| offset: int = 0, | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """List all papers in the library.""" | |
| docs = await doc_service.list_documents(db, limit=limit, offset=offset) | |
| total = await doc_service.count_documents(db) | |
| return DocumentListResponse( | |
| documents=[DocumentResponse(**d) for d in docs], | |
| total=total, | |
| ) | |
| async def get_paper( | |
| paper_id: UUID, | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """Get paper metadata and status.""" | |
| doc = await doc_service.get_document(db, paper_id) | |
| if not doc: | |
| raise DocumentNotFound(str(paper_id)) | |
| return DocumentResponse(**doc) | |
| async def get_paper_progress( | |
| paper_id: UUID, | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """Get current processing status for frontend polling.""" | |
| doc = await doc_service.get_document(db, paper_id) | |
| if not doc: | |
| raise DocumentNotFound(str(paper_id)) | |
| # Also fetch latest job status so the frontend can show accurate | |
| # "Extracting" / "Chunking" / "Embedding" steps while processing. | |
| job_status = None | |
| try: | |
| job_row = await db.execute( | |
| text(""" | |
| SELECT status | |
| FROM ingestion_jobs | |
| WHERE document_id = :doc_id | |
| ORDER BY created_at DESC | |
| LIMIT 1 | |
| """), | |
| {"doc_id": paper_id}, | |
| ) | |
| job = job_row.mappings().first() | |
| if job: | |
| job_status = job["status"] | |
| except Exception: | |
| pass | |
| return { | |
| "paper_id": str(paper_id), | |
| "status": doc["status"], | |
| "job_status": job_status or doc["status"], | |
| "page_count": doc.get("page_count"), | |
| "error_message": doc.get("error_message"), | |
| "extractor": doc.get("extractor"), | |
| } | |
| async def delete_paper( | |
| paper_id: UUID, | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """Delete a paper, its DB rows (cascade), and every on-disk artefact. | |
| Disk cleanup is best-effort: a missing file does NOT prevent the database | |
| row from being removed. | |
| """ | |
| deleted = await doc_service.delete_document(db, paper_id) | |
| if not deleted: | |
| raise DocumentNotFound(str(paper_id)) | |
| await db.commit() | |
| # ── Physical cleanup ────────────────────────────────────────────── | |
| # 1. Raw upload under documents/<filename> | |
| raw_upload = documents_dir() / deleted["filename"] | |
| try: | |
| os.remove(raw_upload) | |
| except FileNotFoundError: | |
| pass | |
| except OSError as e: # permission errors, etc. — log and continue | |
| logger.warning(f"could not remove {raw_upload}: {e}") | |
| # 2. Raw asset copy under assets/<paper_id>.pdf | |
| raw_asset = assets_dir() / f"{paper_id}.pdf" | |
| try: | |
| os.remove(raw_asset) | |
| except FileNotFoundError: | |
| pass | |
| except OSError as e: | |
| logger.warning(f"could not remove {raw_asset}: {e}") | |
| # 3. MinerU extraction directory: extracted/<paper_id>/ | |
| extract_path = extracted_dir() / str(paper_id) | |
| try: | |
| shutil.rmtree(extract_path) | |
| except FileNotFoundError: | |
| pass | |
| except OSError as e: | |
| logger.warning(f"could not rmtree {extract_path}: {e}") | |
| # 4. Image asset directory: images/<paper_id>/ | |
| image_path = images_dir() / str(paper_id) | |
| try: | |
| shutil.rmtree(image_path) | |
| except FileNotFoundError: | |
| pass | |
| except OSError as e: | |
| logger.warning(f"could not rmtree {image_path}: {e}") | |
| async def rechunk_paper( | |
| paper_id: UUID, | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """Re-run the chunker on the existing extracted markdown without re-running MinerU. | |
| Useful after improving the chunker (equation stitching, footnote detection, | |
| Unicode math normalization, …) to apply the new logic to papers already on disk. | |
| Clears existing chunks / embeddings / assets for this doc and rebuilds them | |
| from the cached MinerU output in storage/extracted/<paper_id>/. | |
| """ | |
| import uuid as _uuid | |
| from sqlalchemy import insert | |
| from app.extraction.mineru_client import find_content_list, find_markdown_output | |
| from app.extraction.chunker import ( | |
| create_chunks_from_content_list, | |
| create_chunks_from_markdown, | |
| ) | |
| from app.extraction.assets import move_asset_to_storage | |
| from app.extraction.pipeline_sync import ( | |
| chunks_table, chunk_assets_table, | |
| ) | |
| doc = await doc_service.get_document(db, paper_id) | |
| if not doc: | |
| raise DocumentNotFound(str(paper_id)) | |
| extract_path = extracted_dir() / str(paper_id) | |
| if not extract_path.exists(): | |
| raise HTTPException( | |
| status_code=409, | |
| detail=( | |
| "No cached extraction on disk for this paper — " | |
| "delete and re-upload to re-run MinerU." | |
| ), | |
| ) | |
| # Prefer content_list.json (typed + page-indexed); fall back to markdown. | |
| content_list = find_content_list(extract_path) | |
| if content_list is not None: | |
| chunks = create_chunks_from_content_list(content_list) | |
| source = "content_list.json" | |
| else: | |
| md_file = find_markdown_output(extract_path) | |
| if not md_file: | |
| raise HTTPException( | |
| status_code=409, | |
| detail="Cached extraction has no markdown — delete and re-upload.", | |
| ) | |
| chunks = create_chunks_from_markdown(md_file.read_text(encoding="utf-8")) | |
| source = "markdown" | |
| if not chunks: | |
| raise HTTPException(status_code=500, detail="Re-chunking produced zero chunks.") | |
| # Wipe and rebuild chunks / embeddings / assets atomically. | |
| await db.execute(text(""" | |
| DELETE FROM chunk_embeddings | |
| WHERE chunk_id IN (SELECT id FROM chunks WHERE document_id = :doc_id) | |
| """), {"doc_id": paper_id}) | |
| await db.execute(text(""" | |
| DELETE FROM chunk_assets | |
| WHERE chunk_id IN (SELECT id FROM chunks WHERE document_id = :doc_id) | |
| """), {"doc_id": paper_id}) | |
| await db.execute(text("DELETE FROM chunks WHERE document_id = :doc_id"), | |
| {"doc_id": paper_id}) | |
| # Re-register existing images so chunks can link to them. Images may already | |
| # have been moved to storage/images/<paper_id>; if so, use them directly. | |
| images_root = images_dir() / str(paper_id) | |
| asset_map: dict[str, dict] = {} | |
| if images_root.exists(): | |
| for img in images_root.rglob("*"): | |
| if img.is_file() and img.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".webp"}: | |
| try: | |
| asset_map[img.name] = { | |
| "file_path": str(img.relative_to(images_dir())), | |
| "content": img.read_bytes(), | |
| "mime_type": _guess_image_mime(img.suffix), | |
| } | |
| except Exception: | |
| logger.exception("re-register image read failed for %s", img) | |
| else: | |
| # Fall back to whatever MinerU dropped in the extraction dir. | |
| from app.extraction.mineru_client import find_images | |
| for img_path in find_images(extract_path): | |
| try: | |
| meta = move_asset_to_storage(img_path, document_id=str(paper_id)) | |
| content = b"" | |
| try: | |
| content = meta["dest_path"].read_bytes() | |
| except Exception: | |
| logger.exception("re-register asset read failed for %s", meta["dest_path"]) | |
| asset_map[meta["original_name"]] = { | |
| "file_path": meta["file_path"], | |
| "content": content, | |
| "mime_type": meta.get("mime_type") or _guess_image_mime(meta["dest_path"].suffix), | |
| } | |
| except Exception: | |
| logger.exception("re-register image failed for %s", img_path) | |
| # Use raw SQL with explicit ::uuid / ::jsonb / ::text[] casts. The shared | |
| # `chunks_table` is declared with String columns (sized for the sync path); | |
| # asyncpg refuses to coerce varchar→uuid, so we bind via plain text() instead. | |
| chunk_sql = text(""" | |
| INSERT INTO chunks ( | |
| id, document_id, sequence_id, parent_sequence_id, | |
| chunk_type, heading_path, markdown, plain_text, | |
| page_start, page_end, bbox_json, token_count, table_json | |
| ) VALUES ( | |
| :id, :document_id, :sequence_id, :parent_sequence_id, | |
| :chunk_type, CAST(:heading_path AS text[]), :markdown, :plain_text, | |
| :page_start, :page_end, CAST(:bbox_json AS jsonb), | |
| :token_count, CAST(:table_json AS jsonb) | |
| ) | |
| """) | |
| asset_sql = text(""" | |
| INSERT INTO chunk_assets ( | |
| id, chunk_id, asset_type, file_path, mime_type, width, height, caption | |
| ) VALUES ( | |
| :id, :chunk_id, :asset_type, :file_path, :mime_type, :width, :height, :caption | |
| ) | |
| """) | |
| asset_content_sql = text(""" | |
| INSERT INTO chunk_asset_files (asset_id, content) | |
| VALUES (:asset_id, :content) | |
| ON CONFLICT (asset_id) DO UPDATE | |
| SET content = EXCLUDED.content, created_at = NOW() | |
| """) | |
| import json as _json | |
| for chunk in chunks: | |
| chunk_id = _uuid.uuid4() | |
| await db.execute(chunk_sql, { | |
| "id": chunk_id, | |
| "document_id": paper_id, | |
| "sequence_id": chunk["sequence_id"], | |
| "parent_sequence_id": chunk.get("parent_sequence_id"), | |
| "chunk_type": chunk["chunk_type"], | |
| "heading_path": chunk.get("heading_path"), | |
| "markdown": chunk["markdown"], | |
| "plain_text": chunk["plain_text"], | |
| "page_start": chunk.get("page_start"), | |
| "page_end": chunk.get("page_end"), | |
| "bbox_json": _json.dumps(chunk["bbox_json"]) if chunk.get("bbox_json") is not None else None, | |
| "token_count": chunk["token_count"], | |
| "table_json": _json.dumps(chunk["table_json"]) if chunk.get("table_json") is not None else None, | |
| }) | |
| for img_ref in chunk.get("image_refs", []): | |
| if img_ref in asset_map: | |
| info = asset_map[img_ref] | |
| asset_id = _uuid.uuid4() | |
| await db.execute(asset_sql, { | |
| "id": asset_id, | |
| "chunk_id": chunk_id, | |
| "asset_type": "image", | |
| "file_path": info["file_path"], | |
| "mime_type": info.get("mime_type") or "image/png", | |
| "width": None, "height": None, "caption": None, | |
| }) | |
| if info["content"]: | |
| await db.execute(asset_content_sql, { | |
| "asset_id": asset_id, | |
| "content": info["content"], | |
| }) | |
| await db.commit() | |
| # Re-queue embedding generation so chat works again. The rechunk above | |
| # already replaced the chunks in-process; only embeddings need to be | |
| # (re)generated, so dispatch embed_document (not the full ingestion task, | |
| # which requires job_id/filename and would re-run extraction). | |
| try: | |
| embed_document.delay(str(paper_id)) # type: ignore[attr-defined] | |
| except Exception: | |
| logger.exception("could not dispatch re-embedding task after rechunk") | |
| counts: dict[str, int] = {} | |
| for c in chunks: | |
| counts[c["chunk_type"]] = counts.get(c["chunk_type"], 0) + 1 | |
| return { | |
| "paper_id": str(paper_id), | |
| "status": "rechunked", | |
| "source": source, | |
| "chunks_total": len(chunks), | |
| "chunks_by_type": counts, | |
| "message": ( | |
| "Re-chunked from cached extraction. Embeddings are regenerating in " | |
| "the background; chat may be slow until they finish." | |
| ), | |
| } | |
| async def reextract_paper( | |
| paper_id: UUID, | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """Wipe cached extraction artifacts and re-run the full pipeline (MinerU + chunker). | |
| Distinct from /rechunk, which only re-runs the chunker on already-extracted | |
| markdown. /reextract is the one to use after MinerU was installed (or fixed), | |
| or to migrate papers that were initially processed by the PyMuPDF fallback | |
| onto MinerU's higher-fidelity output. | |
| """ | |
| doc = await doc_service.get_document(db, paper_id) | |
| if not doc: | |
| raise DocumentNotFound(str(paper_id)) | |
| # Mark document back to processing + clear any prior error so the UI shows | |
| # the processing overlay again. | |
| await db.execute( | |
| text(""" | |
| UPDATE documents | |
| SET status = 'processing', | |
| error_message = NULL, | |
| extractor = NULL, | |
| updated_at = NOW() | |
| WHERE id = :id | |
| """), | |
| {"id": paper_id}, | |
| ) | |
| # Wipe DB-side: embeddings → assets → chunks. Cascades from chunks would | |
| # handle embeddings, but the explicit order is robust to schema drift. | |
| await db.execute(text(""" | |
| DELETE FROM chunk_embeddings | |
| WHERE chunk_id IN (SELECT id FROM chunks WHERE document_id = :doc_id) | |
| """), {"doc_id": paper_id}) | |
| await db.execute(text(""" | |
| DELETE FROM chunk_assets | |
| WHERE chunk_id IN (SELECT id FROM chunks WHERE document_id = :doc_id) | |
| """), {"doc_id": paper_id}) | |
| await db.execute(text("DELETE FROM chunks WHERE document_id = :doc_id"), | |
| {"doc_id": paper_id}) | |
| await db.commit() | |
| # Wipe cached extraction + extracted images so MinerU runs fresh. | |
| extract_path = extracted_dir() / str(paper_id) | |
| try: | |
| if extract_path.exists(): | |
| shutil.rmtree(extract_path) | |
| except OSError as e: | |
| logger.warning(f"could not rmtree {extract_path}: {e}") | |
| image_path = images_dir() / str(paper_id) | |
| try: | |
| if image_path.exists(): | |
| shutil.rmtree(image_path) | |
| except OSError as e: | |
| logger.warning(f"could not rmtree {image_path}: {e}") | |
| # Create a fresh ingestion job and dispatch. | |
| job = await create_ingestion_job(db, paper_id) | |
| await db.commit() | |
| try: | |
| task = process_ingestion.delay(str(paper_id), str(job["id"]), doc["filename"]) # type: ignore[attr-defined] | |
| await update_job_status_svc( | |
| db, | |
| job["id"], | |
| "queued", | |
| celery_task_id=task.id, | |
| ) | |
| await db.commit() | |
| except Exception as e: | |
| logger.exception("Failed to dispatch reextract") | |
| raise HTTPException(status_code=500, detail=f"Failed to dispatch reextract: {e}") | |
| return { | |
| "paper_id": str(paper_id), | |
| "status": "reextract_queued", | |
| "job_id": str(job["id"]), | |
| "message": ( | |
| "Cached extraction wiped; MinerU is re-running from the original PDF. " | |
| "Poll /progress to watch extracting → chunking → embedding." | |
| ), | |
| } | |
| async def regenerate_section_summaries( | |
| paper_id: UUID, | |
| force: bool = False, | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """ | |
| Explicitly trigger (or re-trigger) high-quality section + paper-level summarization | |
| for a document. | |
| This is useful after changing the chat model, improving prompts, or if the | |
| automatic pass failed for some reason. | |
| Because this is a personal quality-first tool, the author accepts that this | |
| can take many minutes. | |
| """ | |
| doc = await doc_service.get_document(db, paper_id) | |
| if not doc: | |
| raise DocumentNotFound(str(paper_id)) | |
| # Fire the Celery task (idempotent inside the summarizer unless force=True) | |
| try: | |
| generate_section_summaries.delay(str(paper_id)) # type: ignore[attr-defined] | |
| except Exception as e: | |
| logger.exception("Failed to dispatch regenerate summaries") | |
| raise HTTPException(status_code=500, detail=f"Failed to dispatch summarization task: {e}") | |
| return { | |
| "paper_id": str(paper_id), | |
| "status": "summarization_queued", | |
| "message": "High-quality section summarization task has been dispatched. " | |
| "This can take 5-15+ minutes depending on paper length and hardware. " | |
| "Poll /progress or check section_summaries table to monitor.", | |
| "force": force, | |
| } | |
| async def trigger_reading_order_reconstruction( | |
| paper_id: UUID, | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """ | |
| Trigger LLM-based reconstruction of the correct reading order for this paper. | |
| This is especially useful for two-column academic papers where MinerU's | |
| default extraction order can be messy (left/right column confusion, | |
| figures breaking across columns, content continuing on next page in odd ways). | |
| The task sends chunks + bounding boxes to gemma4:26b and asks it to | |
| output the proper logical reading sequence. Results are cached on the document. | |
| After it finishes, the reading view can switch to "AI-corrected order" | |
| for a much more natural D + ↓ experience. | |
| """ | |
| doc = await doc_service.get_document(db, paper_id) | |
| if not doc: | |
| raise DocumentNotFound(str(paper_id)) | |
| try: | |
| reconstruct_reading_order.delay(str(paper_id)) # type: ignore[attr-defined] | |
| except Exception as e: | |
| logger.exception("Failed to dispatch reading order reconstruction") | |
| raise HTTPException(status_code=500, detail=f"Failed to dispatch task: {e}") | |
| return { | |
| "paper_id": str(paper_id), | |
| "status": "reconstruction_queued", | |
| "message": "LLM reading order reconstruction has been started. " | |
| "This usually takes 30–90 seconds depending on paper length. " | |
| "You can poll the document or check the reading view for the result.", | |
| } | |
| def _guess_image_mime(ext: str) -> str: | |
| mapping = { | |
| ".png": "image/png", | |
| ".jpg": "image/jpeg", | |
| ".jpeg": "image/jpeg", | |
| ".gif": "image/gif", | |
| ".svg": "image/svg+xml", | |
| ".webp": "image/webp", | |
| } | |
| return mapping.get(ext.lower()) or "image/png" | |