44from pathlib import Path
55
66from fastapi import APIRouter , HTTPException , Path as PathParam , Query , Request
7- from fastapi .responses import FileResponse , PlainTextResponse , Response , StreamingResponse
7+ from fastapi .responses import (
8+ FileResponse ,
9+ PlainTextResponse ,
10+ Response ,
11+ StreamingResponse ,
12+ )
813
914from ocr_pipeline .config import settings
1015from ocr_pipeline .models .schemas import (
11- HFPublishRequest , HFPublishResponse , PageSummary , RunCreateRequest ,
12- RunCreateResponse , RunDetail , RunDocumentDetail , RunDocumentSummary ,
13- RunSummary , StagedDocumentInfo ,
16+ HFPublishRequest ,
17+ HFPublishResponse ,
18+ PageSummary ,
19+ RunCreateRequest ,
20+ RunCreateResponse ,
21+ RunDetail ,
22+ RunDocumentDetail ,
23+ RunDocumentSummary ,
24+ RunSummary ,
25+ StagedDocumentInfo ,
26+ )
27+ from ocr_pipeline .services .auth_session import (
28+ is_oauth_enabled ,
29+ session_token ,
30+ session_user ,
1431)
15- from ocr_pipeline .services .auth_session import is_oauth_enabled , session_token , session_user
1632from ocr_pipeline .services .db import get_db
1733from ocr_pipeline .services .hf_publisher import publish_run_to_hf
34+ from ocr_pipeline .services .ocr_pair_exporter import OCRPairExporter
1835from ocr_pipeline .services .pdf_renderer import PDFRenderer
1936from ocr_pipeline .services .run_orchestrator import get_orchestrator
2037from ocr_pipeline .services .startup import model_readiness
3350 "source" : ("artifact_source_pdf" , "application/pdf" ),
3451}
3552TEXT_MODES = {
36- "raw" : "artifact_raw_txt" , "raw_txt" : "artifact_raw_txt" ,
37- "txt" : "artifact_clean_txt" , "clean" : "artifact_clean_txt" ,
38- "md" : "artifact_markdown" , "markdown" : "artifact_markdown" ,
53+ "raw" : "artifact_raw_txt" ,
54+ "raw_txt" : "artifact_raw_txt" ,
55+ "txt" : "artifact_clean_txt" ,
56+ "clean" : "artifact_clean_txt" ,
57+ "md" : "artifact_markdown" ,
58+ "markdown" : "artifact_markdown" ,
3959}
4060
4161
@@ -103,6 +123,7 @@ def _doc_summary(row: dict) -> RunDocumentSummary:
103123def _page_summary (row : dict ) -> PageSummary :
104124 def _bool (v ):
105125 return bool (v ) if v is not None else None
126+
106127 return PageSummary (
107128 page_num = row ["page_num" ],
108129 status = row ["status" ],
@@ -149,7 +170,9 @@ def _existing_path(rd: dict, field: str) -> Path:
149170@router .post ("/api/runs" , response_model = RunCreateResponse )
150171async def create_run (request : RunCreateRequest ):
151172 if not model_readiness .ready :
152- raise HTTPException (status_code = 503 , detail = f"Model server not ready: { model_readiness .status } " )
173+ raise HTTPException (
174+ status_code = 503 , detail = f"Model server not ready: { model_readiness .status } "
175+ )
153176 if not request .file_paths :
154177 raise HTTPException (status_code = 400 , detail = "file_paths must not be empty" )
155178
@@ -164,7 +187,9 @@ async def create_run(request: RunCreateRequest):
164187 except FileNotFoundError as exc :
165188 raise HTTPException (status_code = 404 , detail = str (exc ))
166189
167- orchestrator .start (result , strip_refs = request .strip_refs , export_parquet = request .export_parquet )
190+ orchestrator .start (
191+ result , strip_refs = request .strip_refs , export_parquet = request .export_parquet
192+ )
168193
169194 return RunCreateResponse (
170195 run_id = result .run_id ,
@@ -193,29 +218,37 @@ async def list_runs(limit: int = Query(50, ge=1, le=500)):
193218async def get_run (run_id : str = ID ):
194219 run = await _require_run (run_id )
195220 documents = await get_db ().list_run_documents (run_id )
196- return RunDetail (** _run_summary (run ).model_dump (),
197- documents = [_doc_summary (d ) for d in documents ])
221+ return RunDetail (
222+ ** _run_summary (run ).model_dump (), documents = [_doc_summary (d ) for d in documents ]
223+ )
198224
199225
200226@router .delete ("/api/runs/{run_id}" )
201227async def delete_run (run_id : str = ID ):
202228 run = await _require_run (run_id )
203229 if run ["status" ] == "processing" :
204- raise HTTPException (status_code = 409 , detail = "Cannot delete a run that is still processing" )
230+ raise HTTPException (
231+ status_code = 409 , detail = "Cannot delete a run that is still processing"
232+ )
205233 await get_db ().delete_run (run_id )
206234 return {"deleted" : run_id }
207235
208236
209- @router .get ("/api/runs/{run_id}/documents/{document_id}" , response_model = RunDocumentDetail )
237+ @router .get (
238+ "/api/runs/{run_id}/documents/{document_id}" , response_model = RunDocumentDetail
239+ )
210240async def get_run_document (run_id : str = ID , document_id : str = ID ):
211241 rd = await _require_doc (run_id , document_id )
212242 pages = await get_db ().list_pages (run_id , document_id )
213- return RunDocumentDetail (** _doc_summary (rd ).model_dump (),
214- pages = [_page_summary (p ) for p in pages ])
243+ return RunDocumentDetail (
244+ ** _doc_summary (rd ).model_dump (), pages = [_page_summary (p ) for p in pages ]
245+ )
215246
216247
217248@router .get ("/api/runs/{run_id}/documents/{document_id}/text" )
218- async def get_run_document_text (run_id : str = ID , document_id : str = ID , mode : str = "txt" ):
249+ async def get_run_document_text (
250+ run_id : str = ID , document_id : str = ID , mode : str = "txt"
251+ ):
219252 field = TEXT_MODES .get (mode )
220253 if not field :
221254 raise HTTPException (status_code = 400 , detail = "Unsupported mode" )
@@ -267,7 +300,48 @@ async def download_dataset_bundle(run_id: str = ID):
267300 bundle = run .get ("dataset_bundle" )
268301 if not bundle or not Path (bundle ).exists ():
269302 raise HTTPException (status_code = 404 , detail = "Dataset bundle not available" )
270- return FileResponse (bundle , media_type = "application/zip" , filename = Path (bundle ).name )
303+ return FileResponse (
304+ bundle , media_type = "application/zip" , filename = Path (bundle ).name
305+ )
306+
307+
308+ @router .get ("/api/runs/{run_id}/ocr-pairs/download" )
309+ async def download_ocr_pairs (
310+ run_id : str = ID ,
311+ dpi : int = Query (160 , ge = 50 , le = 400 ),
312+ text_mode : str = Query ("clean" , pattern = "^(clean|raw)$" ),
313+ ):
314+ db = get_db ()
315+ run = await _require_run (run_id )
316+ if run ["status" ] != "completed" :
317+ raise HTTPException (status_code = 409 , detail = "Run is not yet completed" )
318+
319+ documents = await db .list_run_documents (run_id )
320+ pages_by_document = {
321+ doc ["document_id" ]: await db .list_pages (run_id , doc ["document_id" ])
322+ for doc in documents
323+ }
324+ catalog_by_document = {
325+ doc ["document_id" ]: await db .get_document (doc ["document_id" ]) or {}
326+ for doc in documents
327+ }
328+ exporter = OCRPairExporter (settings .runs_dir / run_id / "dataset" / "ocr_pairs" )
329+ result = await asyncio .to_thread (
330+ exporter .export_run ,
331+ run = run ,
332+ documents = documents ,
333+ pages_by_document = pages_by_document ,
334+ catalog_by_document = catalog_by_document ,
335+ dpi = dpi ,
336+ text_mode = text_mode ,
337+ )
338+ if result .pages_count == 0 :
339+ raise HTTPException (status_code = 404 , detail = "No completed OCR pages to export" )
340+ return FileResponse (
341+ result .bundle ,
342+ media_type = "application/zip" ,
343+ filename = f"{ run_id } -ocr-pairs.zip" ,
344+ )
271345
272346
273347@router .get ("/api/runs/{run_id}/stream" )
@@ -276,14 +350,18 @@ async def stream_run(run_id: str = ID, after_event_id: int = 0):
276350 orchestrator = get_orchestrator ()
277351
278352 async def gen ():
279- async for event in orchestrator .subscribe (run_id , after_event_id = after_event_id ):
353+ async for event in orchestrator .subscribe (
354+ run_id , after_event_id = after_event_id
355+ ):
280356 yield f"data: { json .dumps (event , ensure_ascii = False , default = str )} \n \n "
281357
282358 return StreamingResponse (gen (), media_type = "text/event-stream" )
283359
284360
285361@router .post ("/api/runs/{run_id}/publish/hf" , response_model = HFPublishResponse )
286- async def publish_to_hf (payload : HFPublishRequest , http_request : Request , run_id : str = ID ):
362+ async def publish_to_hf (
363+ payload : HFPublishRequest , http_request : Request , run_id : str = ID
364+ ):
287365 db = get_db ()
288366 run = await _require_run (run_id )
289367 if run ["status" ] != "completed" :
@@ -293,7 +371,9 @@ async def publish_to_hf(payload: HFPublishRequest, http_request: Request, run_id
293371 # 1. signed-in user's HF OAuth token (preferred — tied to a real user)
294372 # 2. token explicitly passed in the request body (paste-token mode)
295373 # 3. HF_TOKEN env var (single-user / dev fallback, resolved inside publisher)
296- user = session_user (http_request .session ) if hasattr (http_request , "session" ) else None
374+ user = (
375+ session_user (http_request .session ) if hasattr (http_request , "session" ) else None
376+ )
297377 sess_tok = session_token (http_request .session ) if user else None
298378
299379 # If OAuth is enabled and the user is signed in, ignore the body token —
@@ -302,7 +382,9 @@ async def publish_to_hf(payload: HFPublishRequest, http_request: Request, run_id
302382 # publishes entirely so the panel acts as a true gate.
303383 if is_oauth_enabled ():
304384 if not sess_tok :
305- raise HTTPException (status_code = 401 , detail = "Sign in with HuggingFace to publish." )
385+ raise HTTPException (
386+ status_code = 401 , detail = "Sign in with HuggingFace to publish."
387+ )
306388 token = sess_tok
307389 else :
308390 token = payload .token
0 commit comments